Numerical & Tensor Computing
Under every embedding, similarity score, and model forward-pass is array math. This part covers the numerical ecosystem — numpy's ndarray, vectorization, broadcasting — the linear-algebra you need for RAG, then climbs to tensors, automatic differentiation (the heart of deep-learning frameworks), and the dtype/quantization knowledge that makes local LLM inference fit on your machine.
pip install "llama-cpp-python[server]"— runs on CPU / Apple Silicon, no GPU needed
Learning objectives
- Create and reason about numpy arrays, shapes, and dtypes.
- Replace Python loops with vectorized operations (100×+ faster).
- Use broadcasting to combine arrays of different shapes without copies.
- Compute cosine similarity and top-k the way a vector store does.
- Understand tensors and reverse-mode autograd conceptually (build a tiny one).
- Explain float32/float16/bfloat16/int8 and how quantization shrinks models for local inference.
numpy (pip install numpy). The tensor/autograd and local-inference sections are explained conceptually with tiny runnable examples; the heavy libraries (torch, llama-cpp-python) are optional — the goal is understanding, not a GPU.Why numpy (and not Python lists) intermediate
Here's a moment nearly every AI engineer lives through once. You prototype a similarity search with plain Python lists — a few loops, some sum() and zip() — and on a hundred vectors it's instant. Then you point it at a real corpus, a hundred thousand embeddings, and it grinds for minutes per query. Nothing about the logic changed. What changed is that you were doing million-number arithmetic in the slowest possible place: the Python interpreter. numpy exists to move that arithmetic somewhere fast, and understanding why it's faster is the difference between reaching for it reflexively and fighting it.
The plain-language reason comes down to how the numbers are stored. Picture a Python list of floats as a cloakroom full of numbered tickets: each slot in the list doesn't hold a number, it holds a ticket (a pointer) pointing off to a boxed float object living somewhere else in memory entirely. To add two lists element by element, Python must, for every single pair, follow a ticket, unbox the object, check its type, do the add, box the result, and store a new ticket. That bookkeeping — not the addition — is where all the time goes. A numpy array throws the cloakroom out: it's one solid, contiguous slab of raw numbers, all the same type, sitting shoulder-to-shoulder in memory like the array from D1.
That single design choice buys two things at once. First, memory: no per-element object headers, no scattered pointers — often 10× less RAM for the same data, which matters the moment you hold an embedding matrix for a million documents. Second, speed: because the numbers are typed and packed, numpy runs the operation as one tight compiled C loop over the buffer, with no per-element interpreter overhead, frequently using SIMD instructions that do several numbers per CPU cycle — and it releases the GIL (A2) so other threads keep working. The common mistake is to assume numpy is fast because "C is faster than Python"; the deeper truth is that the contiguous typed layout is what makes the fast C loop possible in the first place. That's why the entire ML stack — PyTorch, TensorFlow, every embedding library — is built on this one idea, and why the rest of this page is really about learning to stay in that fast layout instead of accidentally dropping back into Python loops.
Compare the two halves left and right — same three numbers, drawn as the machine actually stores them.
- On the left, the top row of white boxes is the Python list: each slot holds only a
ptr(a pointer), not a number. The dashed arrows chase each pointer off to a separate amber "boxed float" object sitting somewhere unrelated in memory — that hop-and-unbox on every element is the tax. - On the right, the green boxes sit flush against each other: the numbers themselves, all one dtype, in a single block. The solid arrow underneath is numpy sweeping through that block in one pass.
- The payoff is the right-side note: because the data is packed and typed, numpy runs one compiled C loop over the buffer — SIMD, ~10× less RAM, and the GIL released — instead of the interpreter chasing pointers.
In short: the contiguous typed buffer is what makes the fast loop possible — "C is faster" is the symptom, the layout is the cause.
1 · The ndarray & dtypes intermediate
Before you can do anything clever with numpy, you have to internalise two words, because 90% of numpy bugs are really confusion about one of them: shape and dtype. Get comfortable asking every array "what shape are you, and what type are you?" and most of the mysterious errors — the shape mismatches, the silent precision loss, the model that eats twice the RAM you budgeted — stop being mysterious.
Think of an ndarray ("n-dimensional array") as a spreadsheet that can have more than two dimensions. A 1-D array is a single row of numbers (a vector — one embedding). A 2-D array is a grid with rows and columns (a matrix — a whole batch of embeddings stacked up). Go to 3-D and you've got a stack of grids (say, a batch of images, each a grid of pixels). The shape is just the size along each of those dimensions, written as a tuple: (3,) is three numbers in a line, (1000, 384) is a thousand rows of 384 numbers each. Reading a shape out loud — "a thousand vectors, each 384-dimensional" — is a skill you'll use constantly, and it's the fastest way to catch a bug: if the shape isn't what you expected, something upstream went wrong.
The dtype is the second half of the contract: what kind of number sits in every slot — float32, float64, int8. Unlike a Python list, every element in one array shares a single dtype, and that's precisely what lets numpy pack them tightly and compute fast. The dtype also fixes the price: a float32 costs 4 bytes, a float64 costs 8, so shape × dtype-size = exactly how much memory this array eats — no guessing. That formula is worth burning in, because it's how you predict, before you allocate, whether a 7-billion-parameter model will fit on your card. The classic beginner trap here is invisible: numpy defaults to float64, but the ML world runs on float32 (and smaller), so an array you forgot to cast is quietly doubling your memory and slowing your math. One more thing to hold onto as you read the lab: slicing an array (emb[0], emb[:10]) usually gives you a view — a window onto the same memory, not a copy — which is why it's instant and free, but also why writing into a slice changes the original. Fast, and occasionally surprising.
Read the left grid first (the array itself), then follow the arrow to the right (a slice of it).
- The left grid is the ndarray: 3 rows × 4 columns, so its shape is (3, 4). Every cell holds the same dtype (
float32, 4 bytes), which is why the memory is exactly3 × 4 × 4 = 48bytes — shape and dtype together fix the cost, no guessing. - The blue arrow to the green row on the right is
emb[0]: a view, not a copy. It points back at the same memory as row 0 — so it is instant and free, but writing into it changes the original. - The amber note is the payoff trap: the same grid stored as the
float64default costs 8 bytes per cell — double the RAM — which is why the ML world casts tofloat32.
In short: ask every array "what shape, what dtype?" — that answers how much memory it eats and whether a slice is a free view or a costly copy.
pythonimport numpy as np
v = np.array([1.0, 2.0, 3.0], dtype=np.float32)
print(v.shape, v.dtype) # (3,) float32
m = np.zeros((3, 4), dtype=np.float32) # a 3x4 matrix of zeros
print(m.shape, m.nbytes) # (3, 4) 48 (3*4*4 bytes)
# An "embedding matrix": 1000 vectors of dimension 384
emb = np.random.rand(1000, 384).astype(np.float32)
print(emb.shape, emb.nbytes // 1024, "KB") # (1000, 384) ~1500 KB
# Reshape/slice are VIEWS (no copy) — like memoryview in A2
row = emb[0] # a view into row 0
block = emb[:10] # first 10 rows, still a view
An array (numpy calls it an ndarray) is a grid of numbers that are all the same type, packed tightly in memory. Two words you'll see everywhere: shape (how many numbers along each dimension) and dtype (what kind of number — here 32-bit floats). This block just builds a few arrays and asks them about themselves.
np.array([1.0, 2.0, 3.0])makes a 1-D array (a vector).v.shapeis(3,)— three numbers in a single row — andv.dtypeisfloat32because we asked for it.np.zeros((3, 4))makes a 3-by-4 grid (a matrix) filled with zeros..nbytesis the total memory it uses: 3×4 = 12 numbers × 4 bytes each = 48 bytes.embis a fake embedding matrix: 1000 rows, each a 384-number vector — the shape a real embedding model produces for 1000 texts.emb[0]andemb[:10]grab row 0 and the first 10 rows. These are views — windows onto the same memory, not copies — so they're instant and use no extra RAM.
What the output means: (3,) float32, then (3, 4) 48, then the embedding matrix's shape and size (about 1500 KB). The point: shape and dtype together tell you exactly how much memory an array costs.
Try this: Change np.float32 to np.float64 and re-check .nbytes — it doubles, because each number now takes 8 bytes instead of 4.
2 · Vectorization — kill the loop advanced
If you learn one habit from this entire page, make it this one. Vectorization is the single change that most often turns "my script is too slow to use" into "my script is done before I let go of Enter" — and it's also the habit that most reliably marks the difference between someone who imported numpy and someone who actually thinks in it. The good news: it's less a technique to memorise than a reflex to build — every time your fingers start typing for i in range(len(...)) over an array, a little alarm should go off.
Here's the idea in plain terms. "Vectorizing" means describing the operation on the whole array at once — a * b + 1 — instead of walking element by element and doing the arithmetic yourself. It feels like it shouldn't matter; the same multiplications happen either way. But where they happen is everything. A Python for loop does each multiply up in the interpreter, paying that per-element unbox-check-rebox tax from the last section a million times over. Write a * b + 1 and numpy hands the entire job to a single compiled C loop that rips through the contiguous buffer, often several numbers per clock cycle via SIMD. The analogy: looping in Python is filling a swimming pool with a teaspoon; vectorizing is opening the fire hose. Same water, wildly different time.
The mental shift is to stop thinking "for each element, do X" and start reaching for the whole-array vocabulary that expresses the same intent without a loop. There are really only a handful of tools, and this lab shows the core set: elementwise math (a * b + 1 combines matching positions across arrays), reductions that crush an array down to a summary (.sum(), .mean(), .max()), and boolean masks (a[a > 0.9]), which replace filter loops by building a True/False array and keeping only the True positions. The common mistake — and it's a subtle one — is a "half-vectorized" loop that calls a numpy operation inside a Python for: you pay the interpreter tax on every iteration and get almost none of the speedup. The win only lands when the loop itself disappears into a single array expression. When you genuinely can't express something that way, that's what np.where, fancy indexing, and (last resort) np.vectorize are for — but reach for the whole-array form first, every time.
Read the two panels side by side — the red (left) and green (right) both compute a*b + 1, but in very different places.
- The red panel is the Python loop: each small box is one iteration doing
a[i]*b[i]+1, chained by a dashed arrow, repeated a million times. The red line at the bottom names the per-element tax — unbox, type-check, add, rebox — paid inside the interpreter every single step. - The green panel is the vectorized form: the whole expression collapses into one blue "compiled C loop" box, and the solid arrow shows it sweeping the entire buffer in a single pass (with SIMD).
- The payoff is the green line — ≈ 100× faster — for the identical result, because the loop itself vanished from Python into C.
In short: the multiplications are the same; vectorizing just moves the loop out of the interpreter and into one fast C sweep.
pythonimport numpy as np
a = np.random.rand(1_000_000)
b = np.random.rand(1_000_000)
# ✗ Python loop — slow (millions of interpreted iterations)
# out = [a[i]*b[i] + 1 for i in range(len(a))]
# ✓ Vectorized — one C-level call, ~100x faster
out = a * b + 1 # elementwise multiply then add
total = (a * b).sum() # dot-product-like reduction
mean = a.mean(); mx = a.max() # reductions are C loops too
# masking (boolean index) replaces filter loops
big = a[a > 0.9] # all elements > 0.9, vectorized
The single most important numpy habit: do math on whole arrays at once instead of looping over elements yourself. This is called vectorization. The loop still happens, but down in fast compiled C code, not in slow Python — often 100× faster for the same result.
aandbare each a million random numbers.- The commented-out line shows the slow way: a Python
forloop doinga[i]*b[i] + 1one element at a time (a million interpreted steps). out = a * b + 1is the fast way: numpy multiplies every pair and adds 1 across the whole array in one call. This is elementwise math — same position in each array combines together..sum(),.mean(),.max()are reductions: they crunch the whole array down to one number, also in fast C.a[a > 0.9]is a boolean mask:a > 0.9makes an array of True/False, and indexing with it keeps only the elements that were True — replacing a filter loop.
What the output means: No printout here — the point is speed. The vectorized a * b + 1 produces the exact same result as the loop would, but in a fraction of the time.
Try this: Wrap both the loop version and the vectorized version in time.perf_counter() calls and print the difference. You'll typically see 50–100× speedup.
for loop over a numpy array element-by-element, there's almost always a vectorized form that's dramatically faster. Reach for whole-array ops, reductions (.sum/.mean/.max), boolean masks, and np.where before you loop.3 · Broadcasting advanced
Broadcasting is the feature that feels like magic right up until it feels like betrayal. When it works, you add a bias vector to every row of a matrix in one clean expression and marvel at how little code it took. When it goes wrong, you get either a cryptic could not be broadcast together error or — far worse — no error at all and a silently wrong-shaped result that poisons everything downstream. Learning the rule properly is what keeps you on the magic side.
The plain-language idea: broadcasting lets numpy combine arrays of different shapes by pretending to stretch the smaller one to match the bigger one — without actually copying any data. Picture adding a single bias vector of length 384 to all 1000 rows of a matrix. You could physically tile that vector into a full 1000×384 grid and add grid-to-grid, but that wastes memory copying the same numbers a thousand times. Broadcasting is the lazy, smart version: numpy just reuses the one vector for every row, reading it 1000 times from the same place. Think of it like a stencil — one cut-out, stamped across the whole page, never redrawn.
Now the rule that decides whether two shapes are compatible, and it's worth stating precisely because "roughly right" is where the silent bugs live. numpy lines the two shapes up from the right and compares them dimension by dimension. For each pair, they're compatible only if they're equal, or one of them is 1 (the 1 is the dimension that gets stretched). So (1000, 384) with (384,) works — the vector stretches down every row; and (1000, 384) with (1000, 1) works — that column stretches across every column, which is exactly the per-row normalization in the lab. The classic mistake shows up right there: np.linalg.norm(emb, axis=1) returns shape (1000,), and lining (1000,) up from the right against (1000, 384) tries to match 1000 against 384 — mismatch, error. The fix is keepdims=True, which preserves the shape as (1000, 1) so the trailing 1 can broadcast. This is why seasoned numpy users reach for keepdims=True almost automatically, and why, when a broadcast result looks weird, the very first thing to print is the shapes of both operands — nine times out of ten the bug is a dimension that collapsed when you needed it kept.
keepdims=True is what makes the broadcast legal. Aligned from the right, (1000,1) vs (1000,384) matches — the trailing 1 stretches across all 384 columns — while (1000,) tries to match 1000 against 384 and errors.
Both panels line the two shapes up from the right (read the tuples right-justified); the green panel is the fix, the red one is the bug.
- In the green panel the norms array is
(1000, 1). Aligned from the right, its trailing1sits under384— and a1is allowed to stretch. The blue arrow shows that single column being reused across all 384 columns, soemb / normsdivides every row by its own length. - In the red panel the norms array is
(1000,)(nokeepdims). Aligned from the right,1000lands under384— two real, unequal sizes, neither is1— so numpy raises "could not be broadcast". - The payoff: the only difference between the two panels is that trailing
1.keepdims=Truepreserves it; dropping it collapses the shape and breaks the alignment.
In short: align from the right, keep a 1 where you want stretching, and the broadcast just works.
pythonimport numpy as np
emb = np.random.rand(1000, 384).astype(np.float32)
# norms: shape (1000, 1); emb: shape (1000, 384) -> broadcasts across columns
norms = np.linalg.norm(emb, axis=1, keepdims=True) # (1000, 1)
unit = emb / norms # each row scaled to length 1
print(np.linalg.norm(unit[0])) # ~1.0
# Broadcasting rules: compare shapes right-to-left; dims must be equal or 1.
# (1000, 384) with (384,) -> ok (row added to every row)
# (1000, 384) with (1000,1) -> ok (scale each row)
Broadcasting is numpy's rule for combining arrays of different shapes without writing a loop or making copies. It virtually stretches the smaller array to fit the bigger one. Here we use it to scale every row of a matrix to length 1 (a unit vector), which is exactly what you do before comparing embeddings.
np.linalg.norm(emb, axis=1, keepdims=True)measures the length of each row.axis=1means "go along the columns, once per row";keepdims=Truekeeps the result shaped(1000, 1)instead of collapsing to(1000,).unit = emb / normsdivides a(1000, 384)matrix by a(1000, 1)column. Broadcasting stretches that single column across all 384 columns, so every row gets divided by its own length.- The comments at the bottom state the rule: compare shapes right-to-left; each pair of dimensions must be equal, or one of them must be 1 (the 1 is the one that stretches).
What the output means: ~1.0 — after dividing each row by its length, every row is a unit vector (length 1). That's the normalization step that turns a dot product into cosine similarity (next section).
Try this: Print norms.shape and emb.shape side by side, then try emb / np.linalg.norm(emb, axis=1) without keepdims=True — you'll get a shape-mismatch error, which shows why the (1000, 1) shape matters.
4 · Cosine similarity & top-k — the RAG core advanced
This is the section where the abstract array math suddenly becomes the thing you're actually building. Every "chat with your documents" app, every semantic search box, every RAG pipeline in this course, has this exact computation beating at its centre: given a question, find the handful of stored passages most related to it. The two previous sections — vectorization and broadcasting — were the warm-up; this is the payoff, and it's remarkably little code.
Start with the human question: how do you measure whether two pieces of text are "about the same thing" once they've been turned into vectors? The answer is cosine similarity — and the key insight is that it cares about direction, not length. Picture each embedding as an arrow pointing somewhere in space. Two arrows pointing the same way are about the same topic even if one is longer; two pointing at right angles are unrelated. Cosine similarity is just the cosine of the angle between them: 1.0 means identical direction, 0 means perpendicular/unrelated. Using the angle rather than raw distance is what makes it robust — it won't be fooled into thinking a long document and a short one are different just because their vectors have different magnitudes.
Now the trick that makes it fast enough for real corpora. Cosine is formally the dot product divided by both vectors' lengths — but if you normalize every vector to length 1 up front (the exact broadcasting move from section 3), that divisor becomes 1 and cosine similarity collapses into a plain dot product. And a dot product of your query against every one of 10,000 stored vectors is nothing more than a single matrix-vector multiply: corpus @ query, one @, all 10,000 scores at once, in C. That's the whole engine. The last piece is picking the winners: you usually want the top 5, and the beginner instinct — fully sort all 10,000 scores — is wasteful when you only need 5. np.argpartition is the right tool: it does an O(n) partition to shove the k best to the front without ordering the rest, and then you sort just those k. The common mistakes to file away: forgetting to normalize (your "cosine" scores are then meaningless), and reaching for a full argsort when argpartition would do — a detail interviewers love because it shows you know top-k isn't the same problem as sorting.
corpus @ query scores all N docs at once; argpartition then grabs the top-k in O(n) and you sort only those k — never the full corpus.
Follow the blue arrows left to right — that's a query flowing through the four steps a vector store runs on every search.
- Box 1 (normalize) scales every corpus vector and the query to length 1 — the broadcasting move from §3. Box 2 is the engine:
corpus @ query, a single matrix-vector multiply that produces one score per document; because the vectors are unit-length, each dot product is the cosine similarity. - Box 3 (argpartition) is the efficiency step — it shoves the k best scores to the front in O(n) without ordering everything. Box 4 (argsort) then sorts just those k into best-first order.
- The payoff is the bottom box — the top-5 document ids and scores — reached by sorting only the k winners, never the full N. That "top-k ≠ full sort" distinction is the detail the note calls out.
In short: normalize → one matmul for all scores → partition for the top-k → sort just those k. That four-step chain is the retrieval core of every RAG system.
pythonimport numpy as np
def normalize(x):
return x / np.linalg.norm(x, axis=-1, keepdims=True)
corpus = normalize(np.random.rand(10000, 384).astype(np.float32))
query = normalize(np.random.rand(384).astype(np.float32))
# Score ALL 10k docs at once: (10000,384) @ (384,) -> (10000,)
scores = corpus @ query # cosine sim (already normalized)
# Top-5 without a full sort: argpartition is O(n), then sort the 5
k = 5
idx = np.argpartition(-scores, k)[:k] # indices of the 5 best (unordered)
top = idx[np.argsort(-scores[idx])] # order those 5
print(top, scores[top])
This is the heart of semantic search / RAG. Every document and the query are turned into vectors; the most similar documents are the answer. When vectors are normalized to length 1, similarity is just their dot product — multiply matching positions and add them up. A single matrix multiply scores the entire corpus at once.
normalize(x)divides each vector by its length (the broadcasting trick from section 3), so every vector has length 1.corpusis 10,000 normalized document vectors;queryis one normalized query vector, both of dimension 384.scores = corpus @ query— the@is matrix multiplication. It takes the dot product of the query with every one of the 10,000 rows, giving 10,000 similarity scores. Because the vectors are unit-length, each dot product is the cosine similarity (1.0 = identical direction, 0 = unrelated).np.argpartition(-scores, k)[:k]finds the indices of the top 5 scores cheaply, without fully sorting all 10,000.np.argsort(-scores[idx])then sorts just those 5 into ranked order. (The minus signs flip it to largest-first.)
What the output means: Prints the indices of the 5 best-matching documents and their similarity scores, best first. This is precisely what a vector store returns for a query.
Try this: Set query = corpus[7] (copy an existing document as the query) and re-run — document 7 should come back on top with a score very close to 1.0, because a vector is most similar to itself.
argpartition is the numpy equivalent of the heap top-k from D4. A real vector DB (A7) adds an ANN index so you don't score all n at once.5 · SciPy & linear algebra intermediate
Cosine similarity handles the "dense" side of search — but half of a good retrieval system runs on a completely different kind of matrix, and if you store it the naive way you'll run out of memory before you finish loading it. That's the problem this section fixes, and it's where numpy's ecosystem partner, SciPy, earns its place. Think of SciPy as the well-stocked toolbox built on top of numpy: optimization, statistics, signal processing, and — the two things that matter most for AI engineering — sparse matrices and linear-algebra solvers.
The star here is the sparse matrix, and to see why it matters, picture the keyword side of search: a term-document matrix (the guts of TF-IDF and BM25). Rows are words in your vocabulary — maybe 50,000 of them — columns are documents, and each cell counts how often a word appears in a document. The catch: any single document uses only a few hundred distinct words, so almost every cell is zero. Storing that as a normal dense array is like mailing a 50,000-page book to describe a page-long letter — you'd pay for millions of zeros. A sparse matrix stores only the non-zero entries plus their coordinates, so its memory cost is proportional to the actual data, not the empty grid. The .nnz ("number of non-zeros") attribute in the lab is the whole point made concrete: 2 stored values where a dense array would hold 6, and at real vocabulary sizes that ratio becomes thousands-to-one. The mistake to avoid is calling .toarray() on a big sparse matrix "just to look at it" — that materializes every zero and is exactly the memory blow-up you were avoiding.
The second tool, np.linalg.solve, is the quiet workhorse under a startling amount of ML. When you write A x = b, you're saying "find the x that satisfies this whole system of linear equations at once," and solve does it directly — no rearranging by hand, no guessing. Why care? Because fitting a line (or a plane, or a regression) to data is, underneath, exactly this: least-squares regression boils down to solving a linear system. So the same one-liner that answers a toy two-equation puzzle in the lab is the machinery behind fitting models to data. The honest caveat, worth knowing before you lean on it: solve assumes the system has one clean answer (a square, non-singular A); for the over-determined "more data points than unknowns" case that real regression actually is, you reach for np.linalg.lstsq instead — same spirit, built for the messy real shape.
.nnz) scales with the actual data, which at real vocabulary sizes is a thousands-to-one saving.
Compare the two panels: the left is the wasteful dense layout, the right is the sparse one that stores the same information.
- The left grid is the matrix stored densely: all 6 cells occupy memory even though only the two amber cells (
2and1) are non-zero — the four grey zeros are pure waste. Scale the grid to a 50,000-word vocabulary and almost every one of those cells is a zero you paid for. - The right panel is the sparse form: it keeps only the two non-zero entries, each as a coordinate → value pair. Nothing is stored for the zeros.
- The payoff is
.nnz = 2: memory scales with the number of non-zeros, not the grid size. The warning below it — never.toarray()a big sparse matrix — is the trap that undoes the whole saving by re-materializing every zero.
In short: a dense array pays for every cell; a sparse matrix pays only for the values that are actually there — the right choice whenever most of the grid is zero.
pythonimport numpy as np
from scipy import sparse
# A term-document matrix is mostly zeros -> store sparse (memory: O(nonzeros))
tdm = sparse.csr_matrix(np.array([[0, 2, 0], [1, 0, 0]]))
print(tdm.nnz) # 2 stored values, not 6
# Solve Ax = b (numpy) — the workhorse of least-squares, regressions
A = np.array([[3.0, 1], [1, 2]]); b = np.array([9.0, 8])
x = np.linalg.solve(A, b) # [2., 3.]
Two staples of scientific computing. Sparse matrices store only the non-zero entries — a huge memory win when most of the grid is zeros (like a word-counts-per-document table). And np.linalg.solve solves a system of linear equations, the workhorse behind regressions and least-squares fitting.
sparse.csr_matrix(...)takes a normal array that's mostly zeros and stores it compactly.tdm.nnzis the number of non-zeros — here 2, even though the grid has 6 cells. Only the 2 real values cost memory.Aandbdescribe the equations3x + y = 9andx + 2y = 8in matrix form (A x = b).np.linalg.solve(A, b)finds thexthat satisfies both at once — no algebra by hand.
What the output means: tdm.nnz prints 2, and the solve returns [2., 3.] — meaning x=2, y=3. Plug those back in: 3(2)+3 = 9 and 2+2(3) = 8. Correct.
Try this: Change b to [9.0, 9.0] and re-solve to see how the answer shifts. This same solve is what fits a line to data in least-squares regression.
6 · Tensors & automatic differentiation expert advanced
Here's the question this section quietly answers: when people say a model "learns," what is the actual mechanical thing happening? The mystique around deep learning evaporates once you see it, and what you see is smaller than you'd think — a bookkeeping trick sitting on top of the array math you already know. If you build the tiny engine in the lab, you will understand the single most important mechanism in all of modern AI, and you'll understand it because you wrote it, not because someone asserted it.
First, the word tensor, which sounds intimidating and mostly isn't. A tensor is just an n-dimensional array — numpy's ndarray — handed two extra superpowers. One: it can live on a GPU, so those big matrix multiplies run on thousands of cores at once. Two, and this is the interesting one: it remembers the operations that were applied to it. That memory is what makes automatic differentiation possible. Everything else about PyTorch and TensorFlow is engineering around those two additions.
Now the mechanism itself, in plain language. Training a model means nudging its millions of weights in whatever direction reduces the error — and to nudge each weight correctly you need its gradient: "if I wiggle this one weight a hair, how much does the final error change, and which way?" Computing that by hand for millions of weights is hopeless, so frameworks compute it automatically. The trick, called reverse-mode autograd, has two phases. On the forward pass, as you do the arithmetic, each result secretly records who its parents were and how it was made — building a graph of the whole computation. Then backward() walks that graph in reverse, applying the chain rule from calculus to hand each node its share of the gradient. The intuition for the chain rule is a bucket brigade: the error at the end is passed backward hand to hand, and at every junction it splits according to how much each input contributed. The one non-obvious detail — and it's a lovely callback — is the order you must walk the graph: every node has to be processed after all the nodes that fed into it, which is precisely a topological sort of a DAG (the algorithm from D5). The common misconception this section kills is that autograd does "symbolic calculus" or numerical finite-differences; it does neither. It just records a graph of primitive ops, each of which knows its own tiny local derivative, and multiplies those together backwards. That's it. That's how an LLM learns.
f = 8 and remembers each node's parents; backward() seeds grad=1 at f and applies each op's local derivative in reverse — yielding a.grad=4 (=b+1) and b.grad=2 (=a) automatically.
The same graph is traversed twice: follow the solid blue arrows left-to-right for the forward pass, then the dashed grey arrows right-to-left for the backward pass.
- The two white boxes on the left are the inputs
a=2andb=3. Forward, they flow into the bluemulnode (a*b = 6); noteaalso arrows straight into the add — that's the second timeais used, and why its gradient ends up larger. - The green node
f = 8is the final result (the "loss"). Backward starts here by seeding its gradient to1, then the dashed arrows push gradient back through each op, each node multiplying by its own local derivative — the chain rule as a bucket brigade. - The payoff is the two green labels:
a.grad=4andb.grad=2, computed automatically.a's two paths (through the multiply and straight into the add) sum tob+1 = 4;b's single path givesa = 2. Nodes must be visited after their parents — a topological sort (D5).
In short: forward builds the graph, backward walks it in reverse applying local derivatives — no symbolic calculus, just recorded ops multiplied backwards. That is how a model learns which way to nudge each weight.
pythonclass Value:
"""A scalar that remembers how it was computed, so it can backprop."""
def __init__(self, data, _parents=(), _backward=None):
self.data = data
self.grad = 0.0
self._parents = _parents
self._backward = _backward or (lambda: None)
def __add__(self, other):
out = Value(self.data + other.data, (self, other))
def _back():
self.grad += out.grad # d(a+b)/da = 1
other.grad += out.grad
out._backward = _back
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other))
def _back():
self.grad += other.data * out.grad # d(a*b)/da = b
other.grad += self.data * out.grad
out._backward = _back
return out
def backward(self):
self.grad = 1.0
# visit nodes in reverse topological order (D5!) and apply chain rule
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for p in v._parents: build(p)
topo.append(v)
build(self)
for v in reversed(topo):
v._backward()
# f = a*b + a ; df/da = b + 1, df/db = a
a, b = Value(2.0), Value(3.0)
f = a * b + a
f.backward()
print(a.grad, b.grad) # 4.0 (=3+1) 2.0
This tiny Value class is PyTorch in miniature — the mechanism that lets neural nets learn. Each Value holds a number and remembers how it was built. After a calculation you call .backward() and it works out the gradient: how much each input affected the result. That's automatic differentiation (autograd).
- Every
Valuestores itsdata(the number), itsgrad(the gradient, starting at 0), and its_parents(the Values it came from) — so the whole calculation forms a graph. __add__and__mul__define+and*. Each builds a newValueand stashes a small_backfunction saying how to push gradient to its inputs. The comments give the calculus: fora+beach input's share is 1; fora*b,a's share isband vice versa.backward()seeds the final gradient at1.0, orders the nodes so each is handled after its parents (a topological sort), then runs every_backin reverse — the chain rule, applied backwards through the graph.- The demo computes
f = a*b + awitha=2, b=3, thenf.backward()fills in the gradients.
What the output means: 4.0 2.0. By hand: df/da = b + 1 = 3 + 1 = 4 and df/db = a = 2. The engine derived those automatically — that is exactly how a model figures out which way to nudge each weight.
Try this: Change a, b to Value(5.0), Value(-2.0) and predict a.grad (it's b+1 = -1) before running.
7 · dtypes & quantization expert advanced
This is the section that decides whether that impressive open model runs on your laptop or just refuses to load. Every engineer who has tried to run a local LLM has hit the same wall: "7 billion parameters" sounds abstract until you discover it wants 28 GB of RAM you don't have. Quantization is the lever that gets it down to something a normal machine can hold — and the whole idea is built on the dtype knowledge from section 1, now used deliberately as a cost dial.
Start with the arithmetic that makes it concrete. A model's "size" is mostly its weights, and a weight's memory cost is fixed by its dtype: a float32 weight is 4 bytes. So 7 billion weights × 4 bytes ≈ 28 GB — that's the table's top row, and it's why full-precision models are a data-center thing. Halve the bits to 16 (float16 or bfloat16) and you halve the memory to ~14 GB; the useful wrinkle is that bfloat16 keeps float32's range (fewer overflow surprises) at the cost of precision, which is why training loves it. Drop to 8-bit integers and you're at ~7 GB; to 4-bit (the Q4 GGUF files people actually download) and a 7B model fits in ~3.5 GB — comfortably on a laptop. Each halving of bits roughly halves the memory and speeds up the math, because moving fewer bytes is the real bottleneck. There's no free lunch, though, and naming the tradeoff is the point: fewer bits means coarser numbers, so you're trading a little accuracy for a lot of fit-on-my-machine.
So what is quantization, mechanically? It's mapping a range of fine-grained float values onto a small set of integer buckets — the same idea as rounding temperatures to the nearest degree. The lab shows the honest version: find one scale factor so the largest-magnitude weight lands on the top integer (127 for int8), then round every weight to its nearest integer step. Storing those int8 values is 4× smaller than the floats. To use the weights you dequantize — multiply the integers back by the scale — recovering numbers that are close to, but not exactly, the originals. That gap is the quantization error, and here's the intuition that ties it all together: with a fixed number of buckets, a wider spread of values means each bucket has to cover more ground, so rounding loses more. That's why the "try this" nudges you to scale the weights up and watch the error grow, and it's why real quantization schemes work hard to keep outlier weights from stretching the range for everyone else. The naive whole-tensor scale in the lab is the teaching version; production methods (per-channel scales, GPTQ, AWQ) are refinements of exactly this move.
Read the four horizontal bars top to bottom — each is the memory the same 7-billion-parameter model needs at a smaller dtype, so the bars shrink as you descend.
- Each bar's length is proportional to its memory:
float32(red, ~28 GB) is the full bar;float16/bf16is half of it (~14 GB);int8half again (~7 GB);int4half once more (~3.5 GB). Every step down is one halving of the bit-width. - The label on each bar names the trade-off, not just the size —
bf16keeps float32's range,int8costs a little quality,int4/Q4 GGUF is the one that fits a laptop. - The payoff is the downward arrow and the bottom note: fewer bits means smaller and faster (moving fewer bytes is the bottleneck), paid for with coarser numbers — the quantization error the lab measures.
In short: memory = params × bytes-per-weight, so each drop in dtype halves the footprint — that is the whole lever that makes local inference possible.
| dtype | bits | 7B model ≈ | Use |
|---|---|---|---|
| float32 | 32 | ~28 GB | training default, full precision |
| float16 / bfloat16 | 16 | ~14 GB | GPU inference/training (bf16 = wider range) |
| int8 | 8 | ~7 GB | quantized inference, small quality loss |
| int4 (e.g. Q4) | 4 | ~3.5 GB | local/laptop inference (GGUF) |
pythonimport numpy as np
weights = np.random.randn(1000).astype(np.float32) # trained weights
# Symmetric int8 quantization: map [-max, max] -> [-127, 127]
scale = np.abs(weights).max() / 127
q = np.round(weights / scale).astype(np.int8) # 4x smaller than float32
dequant = q.astype(np.float32) * scale # approximate original
err = np.abs(weights - dequant).mean()
print(q.nbytes, weights.nbytes, f"mean err={err:.5f}") # 1000 vs 4000 bytes
Quantization shrinks a model by storing its weights in fewer bits — here going from 32-bit floats to 8-bit integers, 4× smaller. The trick is to find one scale factor that maps the range of float values onto the integers -127…127, then multiply back when you need the (approximate) original.
weightsis 1000 random float32 numbers standing in for trained model weights.scale = np.abs(weights).max() / 127finds how much one integer step is worth: the biggest magnitude weight maps to 127.q = np.round(weights / scale).astype(np.int8)is the quantize step — divide by the scale and round to whole int8 values. Thisqis what actually gets stored, at 1 byte each.dequant = q.astype(np.float32) * scaleis the de-quantize step — multiply back to recover a close-but-not-exact float.errmeasures the average difference lost to rounding.
What the output means: 1000 4000 (bytes for int8 vs float32 — 4× smaller) and a tiny mean err. The small error is the price of the memory savings; for inference it's usually negligible.
Try this: Multiply weights by 100 before quantizing and watch mean err grow — a wider value range means each int8 step covers more, so rounding loses more precision.
8 · Local LLM inference expert expert
This is where every thread on the page ties together into one running program. The contiguous arrays, the vectorized matmuls, the tensors, the quantized weights — a local LLM generating text is all of those at once, in a loop. If the inner workings of ChatGPT or a local model have ever felt like a black box, this section pries the lid off, and the reveal is almost anticlimactic: it's a short loop wrapped around math you've now seen.
The mental model to hold is that an LLM does not write a sentence; it writes one token, then does the whole thing again to write the next, and again, until it decides to stop. Picture someone writing a message one keystroke at a time, re-reading everything written so far before choosing each next character — that's the loop. Concretely, four steps repeat: (1) a forward pass takes the tokens so far and, through a stack of big matrix multiplies — the exact math from sections 2–4, now running on quantized weights from section 7 — produces logits, one raw score for every possible next token; (2) softmax turns those thousands of scores into a probability distribution; (3) the model samples one token from that distribution; (4) it appends that token and loops back. Stop when it emits an end-of-sequence token or hits your length limit. Getting the model onto your machine is just the setup before this loop: download a quantized file (usually GGUF for CPU/Apple-Silicon via llama.cpp, or GPTQ/AWQ for GPU) and load it with a runtime.
The one piece of real code here is softmax, and it hides the single most-asked numerical-stability question in ML interviews, so it's worth slowing down on. Softmax exponentiates every logit and divides by the total, converting arbitrary scores into positive numbers that sum to 1. The trap: np.exp of a large logit overflows to infinity, and you get nan — garbage that silently corrupts everything after it. The fix is the line z = z - z.max(): subtract the biggest logit from all of them before exponentiating. Because that same shift appears in both the numerator and the denominator of the final ratio, it cancels perfectly — the probabilities are mathematically identical — but now the largest exponent is e^0 = 1 and nothing overflows. It's a free safety net, and knowing why it's free (the cancellation) is what interviewers are probing for. The other knob to notice is temperature, which divides the logits before softmax: high temperature flattens the distribution (more random, more "creative"), low temperature sharpens it toward the top choice (more focused, more deterministic) — the very same temperature slider the Anthropic API and every other LLM API exposes. Understanding it here means understanding what that parameter actually does everywhere else.
Follow the blue arrows around the loop — the model does not emit a sentence, it goes around this ring once for every single token.
- forward runs the tokens so far through the stack of matrix multiplies (the math from §2–4, on the quantized weights from §7) and produces logits — one raw score per possible next token. softmax divides by temperature and turns those scores into probabilities that sum to 1.
- sample draws one token from that distribution; append token adds it to the sequence. The long curved arrow back to forward is the loop: the next pass sees the now-longer sequence.
- The green box is the exit: the dashed arrow off "append" checks whether the model emitted an end-of-sequence token or hit
max_tokens— if so, the loop stops.
In short: forward → softmax → sample → append → repeat, one lap per token, until EOS or the length cap. Everything else on this page is the math inside that first box.
python# With llama-cpp-python (pip install llama-cpp-python), a quantized local model:
#
# from llama_cpp import Llama
# llm = Llama(model_path="model-Q4_K_M.gguf", n_ctx=4096)
# out = llm("Explain RAG in one sentence.", max_tokens=64)
# print(out["choices"][0]["text"])
#
# Under the hood each step is:
# logits = forward(tokens) # big matmuls (this page's math), on CPU/GPU
# probs = softmax(logits / temperature)
# next = sample(probs, top_p=..., top_k=...) # pick a token
# tokens.append(next) # then repeat until EOS or max_tokens
import numpy as np
def softmax(logits, temp=1.0):
z = logits / temp
z = z - z.max() # numerical stability (avoid overflow)
e = np.exp(z)
return e / e.sum()
print(softmax(np.array([2.0, 1.0, 0.1])).round(3)) # [0.659 0.242 0.099]
The commented lines sketch the token-generation loop every LLM runs: a forward pass produces logits (a raw score per possible next token), softmax turns those scores into probabilities, and the model samples one token, appends it, and repeats. The runnable part is softmax itself.
- softmax converts a list of arbitrary numbers into probabilities — all positive and summing to 1 — by exponentiating each and dividing by the total.
z = z - z.max()is the one line interviewers look for: subtracting the largest value first preventsnp.expfrom overflowing on big logits. It doesn't change the answer because the shift cancels in the division.temp(temperature) divides the logits first: higher temperature flattens the probabilities (more random/creative), lower sharpens them (more focused) — the same knob the API exposes.e / e.sum()normalizes the exponentiated values so they add up to 1.
What the output means: [0.659 0.242 0.099] — the three input scores [2.0, 1.0, 0.1] became probabilities that sum to 1, with the biggest logit getting the biggest share. The model would most likely pick token 0.
Try this: Call softmax(np.array([2.0, 1.0, 0.1]), temp=0.5) and compare — the lower temperature makes the top probability even larger (more confident, less random).
claude-opus-4-8): best quality, no ops, pay per token. Local: data never leaves the box, no per-token cost, works offline — but you manage hardware, quantization quality trade-offs, and lower ceiling. Many production systems route: local small model for cheap/high-volume/private steps, frontier API for the hard reasoning. The temperature/top-p/top-k sampling knobs are the same ones the API exposes.Exercises expert
- Vectorize a pure-Python cosine similarity and time it against the numpy version on 100k vectors.
- Use broadcasting to mean-center every column of a matrix in one line.
- Implement top-k retrieval two ways (
argsortvsargpartition) and compare timing for k=10, n=1M. - Extend the
Valueautograd class with atanh(or ReLU) op and verify its gradient numerically. - Quantize a random weight matrix to int8 and plot/compute how mean error changes with the value range.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
The #1 detail: subtract the max before exp so large logits don't overflow. It cancels in the ratio.
pythonimport numpy as np
def softmax(logits):
z = logits - np.max(logits) # stability
e = np.exp(z)
return e / e.sum()
The bare interview version of softmax — turn scores into probabilities that sum to 1. Interviewers want to see the numerical-stability trick, not fancy code.
z = logits - np.max(logits)subtracts the largest score first. This is the make-or-break detail: without it,np.expof a large logit overflows to infinity and you getnan. The subtraction cancels out in the final ratio, so the result is identical — just safe.np.exp(z)raises e to each value (all now ≤ 0, so all safely between 0 and 1), ande / e.sum()scales them to add up to exactly 1.
Try this: Feed in np.array([1000.0, 1001.0]). This function handles it fine; a naive version without the - np.max line would return nan. That's the whole point of the question.
The signature GenAI question: score = softmax(QKᵀ/√d), then weight V.
pythonimport numpy as np
def attention(Q, K, V):
d = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d)
scores -= scores.max(axis=-1, keepdims=True)
w = np.exp(scores); w /= w.sum(axis=-1, keepdims=True)
return w @ V
Scaled dot-product attention is the operation inside a Transformer (and thus every LLM). Plain-English version: for each query, score how well it matches every key, turn those scores into weights with softmax, then take a weighted average of the values. This function is that formula in four lines.
d = Q.shape[-1]reads the vector dimension — used to scale the scores.scores = Q @ K.T / np.sqrt(d)— every query dotted with every key gives a match score. Dividing by√dkeeps the numbers from getting huge as dimension grows, so softmax stays well-behaved.- The next two lines are softmax applied per row: subtract each row's max for stability, exponentiate, then divide by the row's sum so each row of weights adds to 1.
axis=-1, keepdims=Truemakes that happen independently for each query. w @ Vuses those weights to take a weighted average of the value vectors — the attention output: each query's answer is a blend of the values it attended to most.
What the output means: Returns one output vector per query — each a weighted mix of the V rows, weighted toward the keys that matched that query best. Stack this operation many times and you have the core of a Transformer.
Try this: Make Q, K, V small (e.g. shape (2, 4)) and print w — confirm each row sums to 1. Those rows are the attention weights.
Checkpoint expert
- Explain why numpy beats lists and use views/dtypes deliberately.
- Vectorize loops and apply broadcasting rules confidently.
- Implement cosine-similarity top-k as a vector store does.
- Describe reverse-mode autograd (and its link to topological sort).
- Reason about float32/16/int8/int4 trade-offs and outline the local-inference loop.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Vectorized numpy runs the loop in C over a contiguous buffer — the same result far faster with no Python-level loop, which is the whole point of numerical Python.
Your task: Replace a Python loop that squares and sums a list with a single vectorized numpy expression, and confirm the results match.
Requirements:
- Compute the squared-sum with a Python loop as the reference
- Compute the same with one vectorized numpy expression (no Python loop over elements)
- Confirm the two results are equal
- Return a plain int/scalar for the comparison
💡 Hint: Build the array and square-and-sum it in one expression — the C loop replaces the Python one, and the answers should match exactly.
Show solution
Vectorized numpy runs the loop in C over a contiguous buffer — same result, far faster, no Python-level loop.
import numpy as np
data = list(range(10))
loop = sum(x*x for x in data) # Python loop
vec = int((np.arange(10) ** 2).sum()) # vectorized
print(loop, vec, loop == vec) # 285 285 True
Context: Broadcasting lets a (n,1) column of norms divide an (n,d) matrix row-wise with no copy and no loop — the everyday numpy skill behind normalizing embeddings.
Your task: L2-normalize each row of a 2D array so every row has unit length, using broadcasting (no explicit loop).
Requirements:
- Compute per-row L2 norms with
keepdims=Trueso the shape is (n,1) - Divide the matrix by the norms via broadcasting (no explicit loop)
- Every output row has unit length (norm ~1.0)
- Handle the shape alignment (n,1) against (n,d) correctly
💡 Hint: keepdims=True gives a column vector that broadcasts cleanly against the matrix — then each row divides by its own norm.
Show solution
Broadcasting lets a (n,1) column of norms divide an (n,d) matrix row-wise without copying or looping.
import numpy as np
X = np.array([[3.0, 4.0], [1.0, 0.0], [0.0, 2.0]])
norms = np.linalg.norm(X, axis=1, keepdims=True) # shape (3,1)
unit = X / norms # broadcast (3,1)->(3,2)
print(np.round(unit, 3))
print(np.round(np.linalg.norm(unit, axis=1), 6)) # [1. 1. 1.]
Context: Normalizing once turns cosine similarity into a matrix-vector dot product, and argpartition/argsort give top-k — the exact kernel a vector store runs on every query.
Your task: Given a query vector and a matrix of document vectors, return the indices of the top-k most similar rows by cosine similarity, vectorized.
Requirements:
- Normalize the query and the document rows so cosine becomes a dot product
- Compute all similarities with one matrix-vector product
- Select the top-k with
argpartition/argsort(not a Python loop) - Return the top-k indices ordered best-first
- The row closest to the query ranks first
💡 Hint: Once vectors are unit-length the dot product is cosine — then partition for the top-k and sort just those k.
Show solution
Normalize once so cosine is a matrix-vector dot product, then argpartition/argsort for top-k — the exact kernel a vector store runs.
import numpy as np
def top_k(query, docs, k=3):
q = query / np.linalg.norm(query)
D = docs / np.linalg.norm(docs, axis=1, keepdims=True)
sims = D @ q # (n,) cosine scores
idx = np.argpartition(-sims, k-1)[:k] # top-k unordered
return idx[np.argsort(-sims[idx])], sims # then sort those k
rng = np.random.default_rng(0)
docs = rng.random((8, 5))
q = docs[2] + 0.01 # near doc 2
idx, sims = top_k(q, docs, k=3)
print("top-3 doc ids:", idx.tolist()) # doc 2 should lead
print("scores:", np.round(sims[idx], 3))
Context: The naive softmax overflows on large logits; subtracting the row max before exp shifts values into a safe range without changing the result (softmax is shift-invariant) — a core stability trick in numerical and LLM code.
Your task: Implement the numerically stable softmax (max-subtraction) and show it survives inputs that break the naive version.
Requirements:
- Show the naive softmax overflows (nan/inf) on large logits
- Subtract the max before exponentiating in the stable version
- The stable output is finite and sums to 1
- Explain why the shift doesn't change the result (shift-invariance)
💡 Hint: The max-subtraction cancels in the numerator/denominator ratio, so the probabilities are identical — only the overflow disappears.
Show solution
Subtracting the row max before exp shifts values into a safe range without changing the result (softmax is shift-invariant) — a core stability trick in numerical/LLM code.
import numpy as np
def softmax_naive(x):
e = np.exp(x)
return e / e.sum()
def softmax_stable(x):
z = x - np.max(x) # shift-invariant, prevents overflow
e = np.exp(z)
return e / e.sum()
big = np.array([1000.0, 1001.0, 1002.0])
print("naive:", softmax_naive(big)) # nan/inf -- overflow
print("stable:", np.round(softmax_stable(big), 4)) # [0.09 0.2447 0.6652]
print("sums to 1:", round(float(softmax_stable(big).sum()), 6))
Context: Quantization maps floats to a small integer range via a scale; dequantizing approximates the original, and the reconstruction error is the price paid for 4x smaller weights — the mechanism behind weight quantization.
Your task: Quantize a float32 vector to int8 with a scale, dequantize, and measure the reconstruction error.
Requirements:
- Derive a scale from the data (e.g. symmetric int8 using the max absolute value)
- Quantize to int8 and dequantize back to float
- Measure the reconstruction error (e.g. max absolute error)
- Show the size reduction (float32 bytes vs int8 bytes)
💡 Hint: The scale maps the float range onto [-127, 127]; dequantizing multiplies back, and the rounding gap is your error.
Show solution
Quantization maps floats to a small integer range via a scale; dequantizing approximates the original. The error is the price paid for 4x smaller weights.
import numpy as np
def quantize(x):
scale = np.abs(x).max() / 127.0 # symmetric int8
q = np.round(x / scale).astype(np.int8)
return q, scale
def dequantize(q, scale):
return q.astype(np.float32) * scale
x = np.array([0.12, -0.98, 0.55, 0.03, -0.44], dtype=np.float32)
q, scale = quantize(x)
x2 = dequantize(q, scale)
print("int8:", q.tolist())
print("max abs error:", round(float(np.abs(x - x2).max()), 5))
print("bytes: %d -> %d" % (x.nbytes, q.nbytes)) # 20 -> 5
Context: A production vector index stores vectors pre-normalized and scores a whole query batch with one matrix-matrix product — turning N small BLAS calls into one large one for far better cache use and throughput.
Your task: Build a VectorIndex with add(ids, vecs) storing normalized vectors and search(queries, k) scoring a whole batch of queries at once, returning top-k ids per query. Explain why batching matters.
Requirements:
addstores vectors pre-normalized alongside their idssearchscores an entire query batch with one matrix-matrix product (not a loop of dot products)- Return top-k (id, score) per query, best-first
- Handle
klarger than the stored count without crashing - Explain that batching turns many small BLAS calls into one large one for throughput
💡 Hint: Pre-normalizing at insert time means search is a single Q @ M.T matmul — correctness matches the beginner kernel, the win is throughput.
Show solution
Design: store vectors pre-normalized so search is one matrix-matrix product for a whole query batch, not a loop of dot products. Batching turns N small BLAS calls into one large one — dramatically better cache and throughput, which is how real indexes serve concurrent queries.
import numpy as np
class VectorIndex:
def __init__(self):
self.ids = []
self.M = None
def _norm(self, V):
V = np.asarray(V, dtype=np.float32)
return V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-9)
def add(self, ids, vecs):
V = self._norm(vecs)
self.M = V if self.M is None else np.vstack([self.M, V])
self.ids.extend(ids)
def search(self, queries, k=3):
Q = self._norm(queries) # (q, d)
sims = Q @ self.M.T # (q, n) all queries at once
k = min(k, self.M.shape[0])
part = np.argpartition(-sims, k-1, axis=1)[:, :k]
out = []
for r in range(Q.shape[0]):
cols = part[r][np.argsort(-sims[r, part[r]])]
out.append([(self.ids[c], round(float(sims[r, c]), 3)) for c in cols])
return out
rng = np.random.default_rng(1)
idx = VectorIndex()
idx.add([f"d{i}" for i in range(6)], rng.random((6, 4)))
print(idx.search(rng.random((2, 4)), k=2)) # top-2 (id, score) per queryLesson: the correctness (normalize + cosine + top-k) is the same as the beginner kernel; the production concern is throughput — batch the queries into one matmul and let BLAS parallelize, instead of a Python loop over queries.
Knowledge check check yourself
In the vectorized semantic-search example, why does normalizing the corpus and query vectors let a single corpus @ query matrix-vector multiply compute cosine similarity for the whole corpus?
Show answer
The softmax implementation subtracts z.max() before exponentiating. What goes wrong without it, and why is the result still identical?