Welcome back to the Practical AI Engineer roadmap. In Lesson 1 we moved successfully into the Python environment. Before we can train complex deep learning models β Multi-Layer Perceptrons (MLP), CNNs, Transformers β we have to understand the shared language of the AI world: linear algebra and calculus.
For a web developer with no advanced maths background, the formulas taught at university tend to be dry and hard to picture. This lesson is built to change that completely: we start from the most visual, geometric meaning of each idea, then implement matrix multiplication and derivative loops in raw Python so you can see exactly how a machine "learns" from data.
for loop and a function in Python. That's all. Every code block here runs in pure Python
with nothing to install β except exactly one block in section 2.2 that needs NumPy, and that
block can be skipped with no loss. You don't need: any university maths. You don't need to have studied calculus, and you don't need to remember any differentiation rules. The three hardest ideas in this lesson β linearity, derivatives, and the loss function β are each defined from scratch at the point where they first appear.
As for MLP, CNN and Transformer just mentioned above: those are names of model architectures you'll meet from Lesson 5 onwards. You don't need them here β they're named only so you know what this groundwork is for.
One note on how to read: boxes marked π¬ or π§ go deeper than required. Skip them entirely on a first pass and you will still understand the lesson and complete the project at the end.
2.1 The geometric meaning of vectors & matrices
Let's start by turning the real world into numbers. In computing, every kind of input β an image, an audio file, a piece of writing β is represented as an array of numbers. In mathematics we call those arrays vectors and matrices.
-
Vector: a one-dimensional array of numbers. Through a programmer's lens, a
3-dimensional vector is simply a list with 3 elements:
v = [x_1, x_2, x_3]. Geometrically, a vector is an arrow pointing from the origin $O(0, 0, 0)$ to the point with those coordinates in space. -
Matrix: a two-dimensional array of numbers β a table of rows and columns. A $2 \times
3$ matrix (2 rows, 3 columns), for instance, is written in Python as a list containing lists:
A = [[1, 2, 3], [4, 5, 6]].
The number of elements in a vector sets the dimensionality of the space it lives in. The
vector [3, 1] lives in the two-dimensional plane, so we can draw it on paper; a
768-dimensional vector β the real size of an embedding in Lesson 8 β cannot be drawn, but every operation
below stays exactly the same. That is why the two-dimensional case is worth understanding properly: it is
the only one you can see, and all the others differ from it only in the number.
So what is the core geometric meaning of a matrix? Don't think of it as static cells holding numbers, like a spreadsheet. In machine learning, a matrix represents a linear transformation of space. When we multiply a matrix $A$ by an input vector $x$, we are rotating, stretching or compressing that vector in space to produce a new output vector $y = Ax$:
$$y = Ax = \begin{bmatrix} a_{11} & a_{12} \\ a_{21} & a_{22} \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \end{bmatrix}$$A transformation is linear when it keeps two promises: double the input and the output doubles too, and transforming the sum of two vectors gives exactly the sum of the two separate results. Written out: $A(2x) = 2(Ax)$ and $A(u + v) = Au + Av$.
An everyday way to picture it: linear means the transformation doesn't bend anything. It stretches, squashes, rotates or flips the whole plane, but straight lines stay straight and the origin stays put at the origin.
An example that IS linear: "convert every price into another currency" β multiply everything by one exchange rate. Three times the goods costs three times as much, and you get the same total however you group the items.
An example that is NOT linear: progressive income tax. Double your income and the tax goes up by more than double, because the top slice is taxed at a higher band. The first promise breaks immediately.
This leads to a consequence that matters, and it is the reason Lesson 5 exists: stacking linear transformations still only ever gives you one linear transformation. Which means a neural network made purely of matrices is no more powerful with a hundred layers than with one. To learn bent relationships like progressive tax, a network has to insert a non-linear function between its layers β that is the activation function you'll meet in Lesson 5.
The easiest way to see this is to watch one specific transformation act on one specific vector. Take $A = \begin{bmatrix} 2 & 0 \\ 0 & 0.5 \end{bmatrix}$ and $x = [2, 2]$ β this matrix stretches the horizontal axis by two and squashes the vertical axis to a half:
The solid yellow arrow on the left is $x$; the cyan arrow on the right is $y$; the dashed yellow arrow on the right is where $x$ used to be, for comparison. The matrix doesn't "contain" any data β it does one thing: it pushes every vector in the plane to a new place. Multiply a different vector by it and that vector gets stretched wide and squashed flat by exactly the same ratios.
This is the principle behind a layer of artificial neurons: take an input vector $x$ and push it through a transformation so that things which started out tangled become separable, easier to classify. In the neural-network context that transformation matrix is usually written $W$ and called the weights β the same object as $A$ above, under a different name. The crucial point: the numbers inside $W$ are not chosen by a human, the machine finds them itself. The whole second half of this lesson is about how it finds them.
The dot product β the operation you need before anything else
Before we get to matrix multiplication, there is a much smaller operation to master. Once you have it, matrix multiplication has nothing left to memorise, because it is just this operation repeated.
The dot product of two vectors is: multiply each pair of elements in matching positions, then add everything up. The result is a single number β not a vector. That's where the name comes from in the alternative term "scalar product": two arrows with direction go in, one plain number comes out.
$$A \cdot B = \sum_{i=1}^n a_i b_i$$
An everyday way to picture it: you have a basket [2, 1, 3] β 2 pens, 1 notebook, 3 folders β
and a price list [5, 20, 8]. The bill is $2 \times 5 + 1 \times 20 + 3 \times 8 = 54$. You
just computed a dot product. It is everywhere in AI because it is exactly the operation "weigh up several
factors and settle on one number": each input multiplied by how much it matters, then summed.
Two examples to show where its boundary lies.
-
Works: two people rating the same 3 films out of five,
an = [5, 1, 4]andbinh = [4, 2, 5]. Their dot product is 42 β high, because both gave big scores to the same films. Againstchi = [1, 5, 1], An's dot product is only 14. That number is a measure of "how alike are these two people", and that is genuinely how recommender systems work. -
Doesn't work:
[1, 2]and[1, 2, 3]. Nothing pairs up with the 3, so the operation does not exist β it is not zero. The two vectors must have the same number of dimensions. Remember this condition: it is the root of the shape-mismatch error coming up in the next section, and of the single most common mistake people make writing their first AI code.
Written in pure Python it is as short as the description β one line for the whole operation:
def dot(a, b):
# Two vectors must have the same length, or there is nothing to pair up.
if len(a) != len(b):
raise ValueError(f"length mismatch: {len(a)} vs {len(b)}")
# zip() walks both lists in step, giving (a[0], b[0]), (a[1], b[1]), ...
return sum(x * y for x, y in zip(a, b))
basket, price = [2, 1, 3], [5, 20, 8]
print("total bill:", dot(basket, price)) # 54
an, binh, chi = [5, 1, 4], [4, 2, 5], [1, 5, 1]
print("an . binh =", dot(an, binh)) # 42 -> similar taste
print("an . chi =", dot(an, chi)) # 14 -> different taste
print(dot([1, 2], [1, 2, 3])) # ValueError: length mismatch: 2 vs 3
That last line deliberately blows up. Run it once β meeting the length-mismatch error while you are causing it on purpose is far more pleasant than meeting it later inside a 20-layer model.
This formula explains why the film-rating example above works at all. Since lengths are always positive, the sign and size of the dot product are decided by $\cos(\theta)$ β that is, by direction:
- Two vectors pointing the same way ($\theta = 0^\circ$): $\cos(0) = 1$, the dot product is at its maximum.
- Two vectors at right angles ($\theta = 90^\circ$): $\cos(90^\circ) = 0$, the dot product is zero β the two are entirely unrelated.
- Two vectors pointing opposite ways ($\theta = 180^\circ$): the dot product is negative, and the more opposed they are the more negative it gets.
This is also the foundation of the attention mechanism in Transformer models such as GPT and BERT (Lesson 10): to work out which other word in a sentence a given word relates to, the model takes the dot product between their representation vectors β exactly the one-line operation you just wrote.
2.2 Decoding matrix multiplication
With the dot product in hand, matrix multiplication is no longer a rule to memorise. It is just this: take the dot product of each row on the left with each column on the right. The cell at row $i$, column $j$ of the result is the dot product of row $i$ of the left matrix and column $j$ of the right matrix. Nothing more.
And because a dot product demands two vectors of the same length, the shape condition for matrix multiplication follows on its own, with nothing to remember: the number of columns in the left matrix must equal the number of rows in the right matrix β because however many elements a row on the left has, a column on the right must have exactly that many to pair up with.
If matrix $A$ is $m \times n$ and matrix $B$ is $n \times p$, the result $C = A \cdot B$ is $m \times p$, and each element $C_{ij}$ is given by: $$C_{ij} = \sum_{k=1}^n A_{ik} B_{kj}$$
Look closely at that sum: it is the dot product formula you just wrote, with the variables renamed. If the formula looks forbidding, read it aloud as a sentence: "this cell is the dot product of row $i$ with column $j$".
Written in pure Python it needs three nested for loops: one walking the
rows, one walking the columns, and one β the innermost β which is the dot product being carried out by
hand:
def naive_matrix_multiply(A, B):
m, n = len(A), len(A[0]) # rows and columns of the left matrix
n_B, p = len(B), len(B[0]) # rows and columns of the right matrix
# Columns of A must match rows of B, or the row/column pairs cannot be zipped.
if n != n_B:
raise ValueError(f"cannot multiply: A is {m}x{n} but B is {n_B}x{p}")
# Start with a result matrix full of zeros, then accumulate into it.
C = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m): # walk the rows of A
for j in range(p): # walk the columns of B
for k in range(n): # β this innermost loop IS the dot product
C[i][j] += A[i][k] * B[k][j]
return C
A = [[1, 2], [3, 4]] # 2x2
B = [[5, 6], [7, 8]] # 2x2
print("naive matrix product:", naive_matrix_multiply(A, B))
# Output: [[19.0, 22.0], [43.0, 50.0]]
Check the first cell by hand, to trust the code: row 1 of $A$ is [1, 2], column 1 of $B$ is
[5, 7], and their dot product is $1 \times 5 + 2 \times 7 = 19$ β exactly the number in the
top left corner. Those three loops do nothing but repeat that calculation for all 4 cells.
One small detail that can throw you when you run it: the output prints 19.0 rather than
19, even though every input was a whole number. The cause is the 0.0 on the
initialisation line β add an integer to a float in Python and you get a float back. Change it to
0 and you get integers. Nothing is wrong, but this is the kind of discrepancy that makes a
beginner think they mistyped something, so it saves time to know about it.
Scientific computing libraries such as NumPy and PyTorch do not use Python loops. They push the computation down into heavily optimised C/Fortran machine-code libraries (BLAS β Basic Linear Algebra Subprograms β and LAPACK). Those libraries use cache locality optimisations (splitting a large matrix into small blocks that fit the CPU's L1/L2 cache, to avoid waiting on RAM) along with sophisticated decomposition algorithms such as Strassen ($\mathcal{O}(N^{2.807})$), speeding the computation up by hundreds or thousands of times.
* is not matrix multiplication-
A * Bis element-wise multiplication β each cell multiplied by the cell at the same coordinates. Both matrices must be the same size, and the result is that size too. -
A @ Bis the matrix product, the mathematical row-by-column operation you just wrote by hand.
Worth noting for now:
@ only works on NumPy arrays and PyTorch tensors.
Type it with plain Python lists and you get
TypeError: unsupported operand type(s) for @: 'list' and 'list' β which is why the section
above wrote three loops by hand instead of just typing A @ B.
In practice, getting shapes wrong is the single most common error when writing your first AI code, and NumPy reports it with a fairly confusing message. Knowing how to read that message is worth much more than memorising the rule, so let's cause it on purpose once.
pip install numpy. If you'd rather leave it for later, skipping this block entirely costs you nothing β NumPy is the main subject of Lesson 3, and here it only plays the part of the thing that produces the error message. It is worth reading through, though, because you will meet this exact message many times.
import numpy as np
A = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3)
B = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3) β same shape as A, which does NOT fit
# Columns of A = 3, rows of B = 2. 3 != 2, so the row/column pairs cannot be zipped.
try:
C = A @ B
except ValueError as e:
print("error:", e)
# The fix used most often: .T transposes B, turning (2,3) into (3,2).
C = A @ B.T # (2,3) @ (3,2) -> (2,2), valid
print("after transposing B:\n", C)
# Best habit of all: print both shapes BEFORE the line that fails.
print(A.shape, B.shape) # (2, 3) (2, 3)
The message printed is, word for word (NumPy 2.4) β long, but only three parts need reading:
error: matmul: Input operand 1 has a mismatch in its core dimension 0,
with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 2 is different from 3)
Decoding those three parts:
- "operand 1" β NumPy counts from 0, so operand 0 is $A$ and operand 1 is $B$. The culprit being named is the matrix on the right.
- "core dimension 0" β dimension 0 of $B$, meaning its number of rows.
- "size 2 is different from 3" β $B$ has 2 rows, but 3 are needed to match $A$'s 3 columns. Exactly the "left columns = right rows" rule.
The gufunc signature (n?,k),(k,m?)->(n?,m?) in the middle is that shape rule written in
symbols: the letter k appears in both input slots, meaning
the columns of the first and the rows of the second are forced to be the same number. Once you
can read that line you don't need any explanation at all.
The practical tip that condenses all of the above: whenever you hit a matmul ValueError,
print the .shape of both arrays on the line immediately before the failing one. Almost every
time you will spot which dimension is off, faster than reading the message.
2.3 Derivatives β the compass pointing towards less error
We now have the tools to represent a neural network: matrices and matrix multiplication. But as section 2.1 said, the numbers inside the weight matrix $W$ are not set by a human β the machine has to find them. The rest of this lesson answers that, and the tool is the derivative.
Geometrically, the derivative of a function $f(x)$ at a point is the slope of the line tangent to the graph at that point. It tells you whether the function is rising or falling, and how fast, if we shift $x$ by a tiny amount.
An everyday way to picture it: you are standing on a hillside in thick fog. You cannot see where the valley is, but you can feel which way the ground under your feet tilts, and how steeply. The derivative is that sensation, written as a number. And it is also exactly all the information a training algorithm ever gets β it never sees the answer, it only feels the tilt where it currently stands.
Two examples of what that number says. One: if $f$ is the position of a car over time, its derivative is the speed β a large derivative means moving fast. Two: at the very bottom of the valley the ground is flat, so the derivative is 0. That zero is the signal "we have arrived, stop" β and you will watch the algorithm in section 2.4 stop on exactly that signal.
The theoretical limit definition of the derivative:
$$f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}$$Read the formula aloud to make it land: nudge $x$ to the right by a very small amount $h$, see how much $f$ changes, then divide by $h$ β that is "change vertically divided by change horizontally", which is precisely what a slope is. The $\lim_{h \to 0}$ notation only adds that $h$ has to be infinitely small.
A computer cannot compute an infinitely small limit, but it doesn't need to: just pick an $h$ small enough and evaluate that same expression. This approach is called finite difference, and $h$ is usually taken as $10^{-5}$:
def f(x):
return x**2
def numerical_derivative(func, x, h=1e-5):
# The limit formula, written out literally. No calculus rules needed.
return (func(x + h) - func(x)) / h
slope = numerical_derivative(f, 3)
print(f"approximate derivative at x=3: {slope:.5f}")
# approximate derivative at x=3: 6.00001
# By hand, the exact derivative of x**2 is 2x, so at x=3 the true answer is 6.
What prints is 6.00001, not 6. That 0.00001 discrepancy is not a
bug β it is the leftover $h$, because we used a small finite step in place of "infinitely small". This is
worth remembering: this way of computing derivatives is always slightly wrong, and if you
shrink $h$ to reduce the error you hit the opposite problem β subtracting two nearly equal numbers
destroys the significant digits of a float. That is why real frameworks don't use this method but use
autograd, which gives exact derivatives; you'll meet it in Lesson 4. In this lesson we
accept a small error in exchange for seeing the principle in bare code.
A loss function is a function that takes the model's weights and returns exactly one number: how wrong the model currently is. Lower is better; 0 is perfect. It is the opposite of an exam score β think of it as the number of questions you got wrong: the goal is to drive it to 0.
Why squeeze everything down to one number? Because a derivative only means anything for a single number. Without collapsing "wrongness" into one quantity there is no slope to follow, and nothing to optimise.
An example that works: predicting house prices. The model guesses 3 houses at 2.0 / 3.0 / 5.0 million, and the true prices are 2.2 / 2.5 / 5.1. Square each difference and take the mean: $((-0.2)^2 + 0.5^2 + (-0.1)^2)/3 = 0.1$. That is a complete loss function, known as MSE (Mean Squared Error). Adjust the weights towards a better fit and this number falls β smoothly and continuously, so it has a slope to follow.
An example that does NOT work: "90% of predictions correct". It sounds reasonable, but it cannot be used as a training loss, because it steps: nudge the weights a tiny bit and the number of correct predictions is still the same, the percentage doesn't move, and the derivative is 0 β the compass points at nothing. Only when a prediction flips all the way over does it jump by one step. This is exactly why classification models are trained with cross-entropy (smooth) and only then measured with accuracy. Keep the two apart: the function used for training has to be smooth, the metric reported to humans does not.
In this lesson, the function $f(x) = x^2 - 4x + 4$ in section 2.4 plays the part of the loss, and $x$ plays the part of the single weight. A real model has millions of weights, but the mechanism is exactly this.
With a real loss function, depending on millions of weights $W$ rather than one variable $x$, we take the derivative with respect to each weight in turn, treating all the others as constants. A derivative taken that way is called a partial derivative, written $\frac{\partial f}{\partial w_i}$. Its practical meaning is very concrete: "if I nudge only this one weight, how does the error change?"
Collecting all of those partial derivatives into a single vector gives the gradient vector, written $\nabla f$ (pronounced "nabla f"): $$\nabla f = \left[ \frac{\partial f}{\partial w_1}, \frac{\partial f}{\partial w_2}, \dots, \frac{\partial f}{\partial w_n} \right]$$
Geometrically, the gradient always points in the direction of steepest ascent at that point. So to find the minimum of the error function β the bottom of the valley β we must travel in the opposite direction, $-\nabla f$. That is the core idea of the legendary optimisation algorithm gradient descent.
2.4 Hands-on project: writing gradient descent yourself
Now let's put it all together into a working algorithm. We'll write gradient descent ourselves to find the minimum of: $$f(x) = x^2 - 4x + 4$$ This function can be rewritten as $f(x) = (x-2)^2$, so we already know the answer: the minimum value is 0, at $x = 2$.
Knowing the answer in advance is deliberate. The algorithm will not be told that number β all it may use is the derivative, exactly like the feet-in-the-fog sensation from the previous section. With the answer in hand, we can check whether it really arrives, instead of having to take it on trust.
The loss function of a real neural network is not convex: it looks more like a jagged mountain range full of dips of every size. Walking downhill can drop you into a shallow dip (a local minimum) and stop there, even though somewhere else is far deeper. The comforting paradox is that in practice this is usually less bad than it sounds: with a model of very many dimensions, most of those shallow dips are good enough to use.
Put another way: the algorithm you are about to write really is the algorithm real models use. Only the terrain is different.
We put the starting point far away, $x_{\text{init}} = 10.0$, then let the loop crawl its own way back using the update rule: $$x_{\text{new}} = x_{\text{old}} - \eta \cdot f'(x_{\text{old}})$$ where $\eta$ (pronounced "eta") is the learning rate, which decides how long each step is.
The thing worth pausing on for a second is the minus sign. The derivative points uphill; we want to go down, so we subtract. That is the entire content of the word "descent" in the algorithm's name, and it is also the easiest part to get muddled about when you are new β the hand-traced table further down shows this minus sign automatically doing the right thing even when $x$ is on the left of the bottom.
Look at the path it takes on the graph before reading the code β the first four steps, with $\eta = 0.1$:
Notice how the early steps are long, then get shorter as it nears the bottom. Nobody programmed it to slow down: near the bottom the slope is small, and step length is proportional to slope, so it brakes by itself. This is a rather beautiful property of the algorithm β move fast where you are far away, move carefully where you are already close.
Two more words in the coming code are worth knowing first. An epoch is one update
iteration β in this lesson, one step downhill (with real data, one epoch means the model has been through
the entire dataset once). A hyperparameter is a value we choose and the
algorithm never adjusts β such as learning_rate or epochs. Keep it distinct from
a parameter, meaning the weights the algorithm finds by itself; here the only parameter is
x. Choosing hyperparameters is your job, finding parameters is the machine's.
def f(x):
"""The loss function we are minimising."""
return x**2 - 4 * x + 4
def numerical_derivative(func, x, h=1e-5):
"""Approximate the derivative with a finite difference."""
return (func(x + h) - func(x)) / h
# Hyperparameters β values WE choose, which the algorithm never changes.
x = 10.0 # starting guess, deliberately far from the answer
learning_rate = 0.1 # how big a step to take each iteration
epochs = 100 # maximum number of steps
tolerance = 1e-6 # stop once x barely moves any more
for epoch in range(1, epochs + 1):
grad = numerical_derivative(f, x)
# Step AGAINST the slope. This one line does all the learning.
x_new = x - learning_rate * grad
# Converged: x stopped moving, so more iterations would change nothing.
if abs(x_new - x) < tolerance:
print(f"Converged early at iteration {epoch}. x = {x_new:.6f}")
x = x_new
break
x = x_new
print(f"Iteration {epoch:02d}: x = {x:.6f} | loss = {f(x):.6f}")
print(f"\nResult: x = {x:.6f} | minimum value: f(x) = {f(x):.6f}")
Run it and you'll see exactly this (with the middle trimmed):
Iteration 01: x = 8.399999 | loss = 40.959987
Iteration 02: x = 7.119998 | loss = 26.214382
Iteration 03: x = 6.095998 | loss = 16.777196
...
Iteration 65: x = 1.999999 | loss = 0.000000
Converged early at iteration 66. x = 1.999998
Result: x = 1.999998 | minimum value: f(x) = 0.000000
Three things to read out of this result. One: the algorithm reached $x = 1.999998$ β
about two parts in a million away from the true answer of exactly $2$, and it was never told the number 2.
Two: it stopped on iteration 66 rather than running all 100, because the
tolerance branch caught the moment $x$ had all but stopped moving.
Three: the value epochs = 100 was not picked at random β with $\eta = 0.1$
it takes until iteration 66 for the step to become small enough to stop; set epochs = 50 and
the loop ends before convergence and you would never see that "Converged early" line at all.
- $\eta = 0.1$ (as used in the lesson): converges on iteration 66, reaching $x = 1.999998$. Fine.
- $\eta = 0.5$: converges on iteration 2. Substantially faster β so don't assume "small is safe, large is dangerous". For this function, 0.5 is the better step size.
- $\eta = 0.0001$ (too small): it takes until iteration 36,887 to stop, and β here is the surprise β it stops at $x = 2.004993$, i.e. still 0.005 away from the answer. Steps so short that the "x has all but stopped moving" condition is satisfied before it genuinely arrives. The algorithm reports convergence without having got there. This failure is much harder to spot than divergence.
- $\eta = 1.0$ (exactly the critical threshold): $x$ bounces back and forth between $10$ and $-6$, forever. It neither converges nor explodes. The loss never falls at all.
-
$\eta = 1.5$ (too large): divergence. $x$ goes $10 \to -14 \to 34 \to -62 \to 130$,
each step overshooting the bottom and landing further out than the last; by iteration 40, $x$ is
around $2.4 \times 10^{11}$. In real training, this is the moment you see the loss become
NaN.
Finally, to see the relationship between the sign of the derivative and the direction $x$ moves β the easiest thing to muddle when starting out β here are the first three iterations traced by hand with $x_{\text{init}} = 10$, $\eta = 0.1$:
| Iteration | current $x$ | $f'(x) = 2x - 4$ | Sign of derivative | $x_{\text{new}} = x - \eta \cdot f'(x)$ |
|---|---|---|---|---|
| 1 | 10.0 | 16 | Positive (+) β graph sloping UP | 10 β 0.1Γ16 = 8.4 (decreased) |
| 2 | 8.4 | 12.8 | Positive (+) β still sloping UP | 8.4 β 0.1Γ12.8 = 7.12 (decreased) |
| 3 | 7.12 | 10.24 | Positive (+) β still sloping UP | 7.12 β 0.1Γ10.24 = 6.096 (decreased) |
The invariant rule throughout: positive derivative β $x$ must DECREASE to go downhill (since we subtract a positive number); conversely if the derivative is NEGATIVE (we are to the left of the minimum at $x=2$), subtracting a negative number makes $x$ INCREASE β so the algorithm always crawls in the right direction towards the minimum at $x=2$ without knowing the answer beforehand, purely from the sign of the derivative at each step.
A small detail so nothing confuses you when comparing: the table uses the exact derivative $f'(x) = 2x - 4$, which is why it gives round values like $16$ and $8.4$. The code uses a finite difference, so it prints $16.000010$ and $8.399999$. That discrepancy is the $h$ discussed in section 2.3 β the table computes by mathematics, the code computes by measurement. Both are correct, they just differ in precision.
How to run this project on your own machine
-
Download
gradient_descent.pyat the end of the lesson, or retype the code above into a new file. - Open a terminal in the folder containing the file. No virtual environment needed, nothing to install β this script uses pure Python only.
-
Run it:
python3 gradient_descent.py(on Windows,python gradient_descent.py). -
Compare the last line with the result above. Then do the part that pays off most: change
learning_rateto0.5, then1.0, then1.5, running it each time, and watch the algorithm converge fast, then oscillate, then explode. Reading about those four behaviours is easy to forget; watching them appear in your own terminal is much harder to forget.
Lesson summary & bridge to what's next
- Achieved: understanding a vector as an arrow, a matrix as a transformation of space rather than a table of numbers, and "linear" as meaning nothing gets bent.
- Achieved: the dot product β and through it, matrix multiplication and its shape condition become things you can derive rather than memorise.
- Achieved: the derivative as a slope you feel underfoot, the loss function as the single number measuring wrongness, and gradient descent as the loop that joins the two so a machine can find its own weights.
- Achieved: the ability to read NumPy's shape-mismatch message, and the knowledge that a wrong learning rate fails in four different ways β of which the gentlest (too small) is the hardest to detect.
Bridge to the next lesson: to push matrix computation up to millions of operations per second without choking the CPU, we need to learn how to parallelise data with the two core libraries NumPy and Pandas, in Lesson 3.
Download the hands-on code for this lesson
The Python file gradient_descent.py β the hands-on source that runs the loop finding a
function's minimum by raw gradient descent, computing the derivative itself (run
python gradient_descent.py, nothing else to install):
Comments