AI EngineeringZero to ProductionHome·About·Contact
Appendix · Advanced AI Engineering · Part 4

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.

⏱️ ~2.5 hours🎯 Intermediate → Expert🔢 arrays → tensorsrunnable
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • pip install "llama-cpp-python[server]" — runs on CPU / Apple Silicon, no GPU needed
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

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.
SetupThis page uses 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

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — arrays, shape, dtype
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
▶ How this works

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.

  1. np.array([1.0, 2.0, 3.0]) makes a 1-D array (a vector). v.shape is (3,) — three numbers in a single row — and v.dtype is float32 because we asked for it.
  2. np.zeros((3, 4)) makes a 3-by-4 grid (a matrix) filled with zeros. .nbytes is the total memory it uses: 3×4 = 12 numbers × 4 bytes each = 48 bytes.
  3. emb is a fake embedding matrix: 1000 rows, each a 384-number vector — the shape a real embedding model produces for 1000 texts.
  4. emb[0] and emb[: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.

Try it — loop vs vectorized
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
▶ How this works

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.

  1. a and b are each a million random numbers.
  2. The commented-out line shows the slow way: a Python for loop doing a[i]*b[i] + 1 one element at a time (a million interpreted steps).
  3. out = a * b + 1 is 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.
  4. .sum(), .mean(), .max() are reductions: they crunch the whole array down to one number, also in fast C.
  5. a[a > 0.9] is a boolean mask: a > 0.9 makes 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.

The ruleIf you're writing a 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.

Try it — normalize every row to unit length
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)
▶ How this works

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.

  1. np.linalg.norm(emb, axis=1, keepdims=True) measures the length of each row. axis=1 means "go along the columns, once per row"; keepdims=True keeps the result shaped (1000, 1) instead of collapsing to (1000,).
  2. unit = emb / norms divides 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.
  3. 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.

Try it — vectorized semantic search
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])
▶ How this works

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.

  1. normalize(x) divides each vector by its length (the broadcasting trick from section 3), so every vector has length 1.
  2. corpus is 10,000 normalized document vectors; query is one normalized query vector, both of dimension 384.
  3. 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).
  4. 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.

🔗 Used in the courseThis is the retrieval math in Ch 3 — the from-scratch RAG scores query vs chunks with cosine similarity. 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).

Try it — sparse matrices & a linear solve
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.]
▶ How this works

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.

  1. sparse.csr_matrix(...) takes a normal array that's mostly zeros and stores it compactly. tdm.nnz is the number of non-zeros — here 2, even though the grid has 6 cells. Only the 2 real values cost memory.
  2. A and b describe the equations 3x + y = 9 and x + 2y = 8 in matrix form (A x = b).
  3. np.linalg.solve(A, b) finds the x that 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.

Try it — a tiny autograd engine (the idea behind PyTorch)
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
▶ How this works

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).

  1. Every Value stores its data (the number), its grad (the gradient, starting at 0), and its _parents (the Values it came from) — so the whole calculation forms a graph.
  2. __add__ and __mul__ define + and *. Each builds a new Value and stashes a small _back function saying how to push gradient to its inputs. The comments give the calculus: for a+b each input's share is 1; for a*b, a's share is b and vice versa.
  3. backward() seeds the final gradient at 1.0, orders the nodes so each is handled after its parents (a topological sort), then runs every _back in reverse — the chain rule, applied backwards through the graph.
  4. The demo computes f = a*b + a with a=2, b=3, then f.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.

This is PyTorch in miniatureReal frameworks do exactly this over tensors instead of scalars, on the GPU, with hundreds of op types — but the mechanism is what you just built: record a graph on the forward pass, walk it in reverse topological order (the topo sort from D5!), and accumulate gradients via the chain rule. That's how an LLM learns.

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.

dtypebits7B model ≈Use
float3232~28 GBtraining default, full precision
float16 / bfloat1616~14 GBGPU inference/training (bf16 = wider range)
int88~7 GBquantized inference, small quality loss
int4 (e.g. Q4)4~3.5 GBlocal/laptop inference (GGUF)
Try it — the quantization idea in numpy
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
▶ How this works

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.

  1. weights is 1000 random float32 numbers standing in for trained model weights.
  2. scale = np.abs(weights).max() / 127 finds how much one integer step is worth: the biggest magnitude weight maps to 127.
  3. q = np.round(weights / scale).astype(np.int8) is the quantize step — divide by the scale and round to whole int8 values. This q is what actually gets stored, at 1 byte each.
  4. dequant = q.astype(np.float32) * scale is the de-quantize step — multiply back to recover a close-but-not-exact float. err measures 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.

The inference loop — conceptually
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]
▶ How this works

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.

  1. softmax converts a list of arbitrary numbers into probabilities — all positive and summing to 1 — by exponentiating each and dividing by the total.
  2. z = z - z.max() is the one line interviewers look for: subtracting the largest value first prevents np.exp from overflowing on big logits. It doesn't change the answer because the shift cancels in the division.
  3. 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.
  4. 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).

When to run local vs APIAPI (this course's default, 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

Practice
  1. Vectorize a pure-Python cosine similarity and time it against the numpy version on 100k vectors.
  2. Use broadcasting to mean-center every column of a matrix in one line.
  3. Implement top-k retrieval two ways (argsort vs argpartition) and compare timing for k=10, n=1M.
  4. Extend the Value autograd class with a tanh (or ReLU) op and verify its gradient numerically.
  5. 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.

Implement softmax (numerically stable)

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()
▶ How this works

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.

  1. z = logits - np.max(logits) subtracts the largest score first. This is the make-or-break detail: without it, np.exp of a large logit overflows to infinity and you get nan. The subtraction cancels out in the final ratio, so the result is identical — just safe.
  2. np.exp(z) raises e to each value (all now ≤ 0, so all safely between 0 and 1), and e / 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.

Implement scaled dot-product attention

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
▶ How this works

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.

  1. d = Q.shape[-1] reads the vector dimension — used to scale the scores.
  2. scores = Q @ K.T / np.sqrt(d) — every query dotted with every key gives a match score. Dividing by √d keeps the numbers from getting huge as dimension grows, so softmax stays well-behaved.
  3. 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=True makes that happen independently for each query.
  4. w @ V uses 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.

Exercise 1 · Vectorize a loopBeginner

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
Exercise 2 · Broadcasting: normalize a matrixIntermediate

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=True so 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.]
Exercise 3 · Cosine similarity + top-k (the RAG core)Advanced

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))
Exercise 4 · Numerically stable softmaxExpert

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))
Exercise 5 · int8 quantization round-tripProfessional

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
Exercise 6 · A minimal in-memory vector index with batched searchIndustry scenario

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:

  • add stores vectors pre-normalized alongside their ids
  • search scores 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 k larger 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 query

Lesson: 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

✓ Knowledge check

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
Cosine similarity is the dot product divided by both vectors' lengths; once every vector is unit-length that divisor is 1, so the plain dot product IS the cosine, and one matmul scores all N documents at once.
✓ Knowledge check

The softmax implementation subtracts z.max() before exponentiating. What goes wrong without it, and why is the result still identical?

Show answer
Without it np.exp overflows on large logits and returns nan/inf; subtracting the max shifts all values so exponents stay in a safe range, and because the shift appears in both numerator and denominator it cancels in the ratio, leaving the probabilities unchanged.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in