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
A Python list of numbers is a list of pointers to boxed int/float objects, scattered in memory (A2). numpy stores a contiguous typed buffer and runs operations as compiled C loops that also release the GIL (A2). The result: 10–100× less memory and speed, which is why the entire ML stack sits on it.
1 · The ndarray & dtypes intermediate
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
The core numpy skill: express computation as operations on whole arrays, not element-by-element loops. The loop still happens — but in C, on contiguous memory, often SIMD-accelerated.
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 lets numpy combine arrays of different shapes by virtually stretching the smaller one — no copies. It's how you normalize a whole matrix by a per-row value, or add a bias vector to every row, in one expression.
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
Semantic search ranks documents by cosine similarity to the query embedding. With normalized vectors, cosine similarity is just a dot product — and scoring the whole corpus is a single matrix-vector multiply.
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
SciPy builds on numpy for scientific computing: optimization, statistics, signal processing, and sparse matrices (crucial for the BM25/TF-IDF side of hybrid search — mostly-zero term-document matrices stored compactly).
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
A tensor is an n-dimensional array (numpy's ndarray with two superpowers): it can live on a GPU, and it records the operations applied to it so gradients can be computed automatically — autograd, the engine that trains every neural net. Reverse-mode autograd builds a graph on the forward pass, then applies the chain rule backwards.
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
Model size and speed hinge on the numeric type of the weights. Fewer bits = less memory and faster math, at some precision cost. Quantization converts trained float weights to low-bit integers so a big model fits on modest hardware.
| 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
Putting it together: to run an open LLM on your own machine you (1) get a quantized model (commonly GGUF for CPU/Metal via llama.cpp, or GPTQ/AWQ for GPU), (2) load it with a runtime, (3) run a forward pass that's mostly big matrix multiplies (this section's math) to produce next-token logits, and (4) sample a token, append it, and repeat.
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?