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.

βœ… What you need before starting β€” and what you don't
You need: Python 3.9 or newer (installed in Lesson 1), and the ability to read a 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}$$
πŸ“ What does "linear" mean? β€” the most frequent word in this lesson
The word linear appears in the name of the field, in the name of the transformation, and in the title of this lesson, so let's pin it down right now.

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:

Before: x = [2, 2] 2 4 2 1 multiply by A After: y = Ax = [4, 1] 2 4 2 1 stretched wide, squashed flat

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] and binh = [4, 2, 5]. Their dot product is 42 β€” high, because both gave big scores to the same films. Against chi = [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:

dot_product.py
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.

🧠 The geometric view: why a dot product measures similarity
Alongside the algebraic definition above, the dot product has an equivalent geometric definition: $$A \cdot B = \|A\| \|B\| \cos(\theta)$$ where $\|A\|$ is the length of the vector and $\theta$ is the angle between the two vectors.

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.
There is a practical consequence worth knowing in advance: because the formula contains $\|A\| \|B\|$, a long vector produces a large dot product even when its direction isn't a close match. To compare direction alone and ignore length, people divide by those two lengths, giving the cosine similarity measure β€” the thing you will use to find text with similar meaning in Lesson 8 and Lesson 15.

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:

matrix_multiply.py
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.

🧠 Why does NumPy multiply matrices hundreds of times faster than a Python loop?
The three-nested-loop algorithm above has time complexity $\mathcal{O}(N^3)$. Working on the large matrices of deep learning β€” say $1000 \times 1000$ β€” the CPU has to perform $10^9$ operations. Running that in raw Python loops is extremely slow, because Python is a dynamic language and spends time checking the type of every element on every pass.

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.
⚠️ Pitfall: * is not matrix multiplication
In NumPy and PyTorch, two similar-looking symbols do completely different jobs:
  • A * B is 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 @ B is the matrix product, the mathematical row-by-column operation you just wrote by hand.
Why this pitfall is expensive: with two square matrices of the same size, both operations succeed and return a matrix of the same shape. There is no error to trace β€” only a model that never gets any better.

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.

πŸ“¦ This one block needs NumPy installed
Every other code block in this lesson runs in pure Python with nothing to install. This one is the sole exception, because the error message we want to look at is produced by NumPy. Install it inside the virtual environment you created in Lesson 1: 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.
shape_mismatch_debug.py
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:

Terminal
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}$:

derivative.py
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.

🎯 What is a "loss function"? β€” the thing all training aims at
This term is about to appear constantly, here and in every remaining lesson of the series, so let's settle it first.

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.

πŸ₯£ "Convex" β€” why we start with an easy function
$f(x) = (x-2)^2$ is a convex function: its graph is bowl-shaped, with exactly one bottom. That property matters because it gives a strong guarantee β€” walk downhill from any starting point whatsoever and you are certain to reach that one bottom. There is nowhere to get stuck.

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

x f(x) = (x-2)Β² target: x = 2 x=10 8.4 7.12 6.096 … 62 more steps tangent at x=10: sloping up

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.

gradient_descent.py
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):

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

πŸ’‘ The learning rate: the most important number, and all four ways it goes wrong
The learning rate $\eta$ decides how long each step is. The numbers below come from actually running the code above on $f(x) = (x-2)^2$ from the starting point $x = 10$ β€” change one line and you can reproduce every one of them:
  • $\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.
The lesson: there is no universally "correct" learning rate, and both extremes carry a cost. This is the hyperparameter you will tune most often throughout the rest of the series.

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

  1. Download gradient_descent.py at the end of the lesson, or retype the code above into a new file.
  2. Open a terminal in the folder containing the file. No virtual environment needed, nothing to install β€” this script uses pure Python only.
  3. Run it: python3 gradient_descent.py (on Windows, python gradient_descent.py).
  4. Compare the last line with the result above. Then do the part that pays off most: change learning_rate to 0.5, then 1.0, then 1.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

πŸ”‘ What you now have:
  • 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):

Download gradient_descent.py

πŸ“– Further reading

Related lessons in this series

Lesson 1: Shifting your programming mindset β€” from JS to Python for AI Lesson 3: Working with large data β€” NumPy & Pandas in depth Back to the Practical AI Engineer roadmap

Comments