Reconstructed from 5 of my own notebooks

How did I get from towers on a coordinate plane to writing my own regression + optimization engine?

This isn't a textbook chapter. It's a reconstruction of my own reasoning — including the parts where I guessed wrong, named a function badly, summed a gradient array instead of a gradient, or let a NumPy array mutate underneath me. Every stage below answers one question: why did I need this concept at exactly this point, and not before?

The question every stage answers a piece of: "I had towers with coordinates and no idea how to relate them — how did that turn into a general parameter-optimization system I built myself?"

Drag the line below. This is literally the object at the center of the entire journey: five power-grid towers, one straight line, and the question of how to make the line "fit" — which is where Stage 1 begins.

Live · the actual tower datatx=[2,4,6,8,9] ty=[3,6,8,2,5]
Total squared error (cost):
tower my line residual (error)

Visual roadmap — click any node to jump there

0Stage 0 — The original problem

Towers, a house, and a cost nobody had defined yet

Before any math, there was a plain-language problem. I wrote it down first, in my own words, before I knew what tool I'd need to solve it.

The problem, in my own words

The first cell of regression_one_tower_problem.ipynb is not code — it's a docstring where I stated the problem before touching NumPy:

PROBLEM STATEMENT : there are n number of the power grid towers that supply electricity to the a house so we need to find the position of the house such that it required minimum cost to connect each tower. calculation of cost = square of distance between tower and the house.

So the original question was about one unknown point — the house — and n known points — the towers. The "cost" of a candidate house location was the sum of squared distances from that single point to every tower. This is a facility-location problem: where do I place one point so that, on average (in a squared sense), it's as close as possible to a set of fixed points?

What information was available, what was unknown

  • Known: the coordinates of every tower — an x and a y value each.
  • Unknown: a location that minimizes total squared distance to all of them.
  • Assumed: that "cost" could be captured entirely by geometric distance, squared.

Why I needed a mathematical model at all

I couldn't just "look" at five towers scattered on a plane and guess the best point — with more than two towers there's no single obvious answer, and "best" only means something once cost is a number I can compare across candidate locations. That's the whole reason a cost function enters the picture at Stage 0, before any regression vocabulary shows up: I needed a way to turn "is this a good location?" into "how small is this number?"

The coordinate interpretation

Every tower became a pair (x, y). That single move — turning a physical tower into a coordinate pair — is what makes the rest of the journey possible. Once towers are points in a plane, any relationship I hypothesize between them (a line, a curve, a location) becomes a question I can answer with algebra instead of intuition.

Where the notebook actually pivots

Here's something I noticed re-reading my own notebook: the docstring describes finding one house point that minimizes squared distance to n towers — a facility-location problem. But the very next cells never build that. Instead, cell 3 defines fxy(m,b,a1,b1) = m*a1 + b - b1 and cell 5 defines cost(tx,ty,m,b) = Σ (m·tx[i] + b − ty[i])² — which is not distance from a point to a set of towers. It's the vertical residual between a line and a set of tower coordinates: ordinary line-fitting. I never explicitly reconciled this shift in the notebook. My best reconstruction of what happened: I started by imagining "one house, many towers," but the moment I sat down to write code, the thing I actually knew how to formalize was "fit a line through points" — so the problem quietly turned into linear regression, using the towers' own coordinates as the data points to fit. I'm preserving this exactly as it happened rather than pretending the docstring and the code always agreed, because this pivot is itself the real origin of "why regression" in this journey.

How the idea of a line arose

Once the towers are treated as (x, y) points that I want to relate to each other, the simplest possible relationship I can hypothesize is a straight line: y = mx + b. It has exactly two unknowns, it's easy to differentiate, and if it fits the towers reasonably well I can use it to reason about the whole configuration — including, eventually, a "best" point along it. This is the geometric idea that Stage 1 formalizes.

What I actually learned — Stage 0
Concepts I can now explain
  • Why a vague real-world problem needs to be translated into coordinates before any optimization is possible.
  • The difference between a facility-location cost (distance to a point) and a curve-fitting cost (residual to a line).
  • Why "cost" has to be a number before "best" means anything.
What confused me

My own problem statement and my own code didn't describe the same problem, and I didn't notice for a while. I was solving line-fitting while still narrating it to myself as "finding the house."

What resolved it

Re-reading the notebook months later, side by side. The resolution isn't "which one was right" — it's realizing both are valid problems, and I'd unconsciously substituted the one I had tools for. That substitution is normal and worth catching, not hiding.

Can I derive it myself?

Given tower coordinates (x₁,y₁)…(xₙ,yₙ), write the squared-distance-to-a-point cost for an unknown house at (h,k), and separately the squared-residual-to-a-line cost for an unknown line y=mx+b. Confirm they involve different unknowns (2 numbers vs. 2 numbers, but geometrically different objects) and different distance directions (full Euclidean vs. vertical-only).

Active recall
Why can't "best house location" be answered by inspection once there are more than 2 towers?
With 2 towers, the best point (minimizing summed squared distance) is just the midpoint — easy to see. With 3+ towers scattered irregularly, there's no point that's simultaneously closest to all of them, so "best" has to be defined numerically (as the minimizer of a cost function) rather than read off a picture.
What's the actual mathematical difference between "distance from a point to a tower" and "residual of a line at a tower's x-coordinate"?
Point-to-point distance is the full Euclidean distance, √((x−h)²+(y−k)²), independent of any direction. A line's residual at a tower is only the vertical gap, (m·x + b) − y — it ignores horizontal distance entirely. They coincide only in special cases (e.g. vertical lines don't even exist in y=mx+b form). This is why "line-fitting" and "closest point" are genuinely different problems, even though both start from the same tower coordinates.
Why does turning towers into (x, y) coordinates matter so much?
It converts a physical, spatial question into an algebraic one. Once towers are numbers, any hypothesis about how they relate (linear, quadratic, multi-feature) can be tested and optimized with calculus and linear algebra instead of geometric intuition alone.

→ Next: once towers are coordinates, what's the simplest relationship I can propose between them?

1Stage 1 — From points to a line

Representing towers as data, and proposing y = mx + b

What I knew before this stage: towers have coordinates. What I didn't know yet: how to write "a line through them" in code.

Representing the tower coordinates

Across the notebooks the same small dataset keeps reappearing — five towers used again and again as the canonical test case, first in regression_multi_tower_problem.ipynb and then reused in optimization_using_newton_method.ipynb:

regression_multi_tower_problem.ipynb
tx = [2,4,6,8,9]
ty = [3,6,8,2,5]

tx holds the x-coordinate of each tower, ty the y-coordinate. Tower i is the point (tx[i], ty[i]). This is the same 5-point dataset driving the hero widget at the top of this page.

Understanding x and y

It's worth being precise about what x and y even are here, because the notebooks never fully pin this down (a good example of a real gap between "problem statement" and "implementation" — see Stage 0). Whatever x and y represent physically, mathematically I only need two things from them: a value I treat as input (x) and a value I'm trying to relate it to (y).

The model: y = mx + b

Model / hypothesisŷ = m·x + b

This is a hypothesis, not a fact: I'm proposing that y can be approximated as a linear function of x. Two unknowns fully describe this hypothesis:

  • m — the slope. Geometrically, how much ŷ changes per unit of x. In the tower context, it's the rate at which the y-coordinate trends upward or downward as x increases across towers.
  • b — the intercept. Where the line crosses x = 0. It's the baseline value of ŷ before x has any effect.

I named the residual function fxy in regression_one_tower_problem.ipynb, even though it isn't really "a function of x and y" — it's a function of m, b, and one point:

regression_one_tower_problem.ipynb — cell 3
# function for calculating a single value f(x,y)
# for two variable for a point in place (a1,b1) cost function c(m,b) = (m·a1 + b - b1)**2

def fxy(m,b,a1,b1):
    return m*a1 + b - b1

This naming is worth keeping rather than "fixing," because it captures exactly where my head was at this point: I was still thinking of this as "a function involving x and y," even though what it actually computes — m·a1 + b − b1 — is the residual: how far the line's prediction at a1 is from the true value b1. Naming it after its inputs rather than what it means is a very natural first-pass mistake, and it's the seed of the vocabulary I properly separate out in Stage 2 (error vs. residual vs. squared error vs. cost).

What happens when the line doesn't pass through the points

Almost always, it doesn't — and it can't, in general, pass through all five towers exactly, because five points impose five constraints on a line that only has two degrees of freedom (m and b). For most datasets a line has to miss some or all of the points. Drag m and b in the hero widget above and watch: for any choice, some towers sit above the line and some below. Those vertical gaps are exactly what Stage 2 needs to measure.

What I actually learned — Stage 1
Concepts I can now explain
  • Representing a set of points as two parallel arrays, tx and ty.
  • y = mx + b as a hypothesis with exactly two free parameters.
  • m as rate-of-change, b as the y = 0 baseline.
What confused me

I called a residual function fxy as if it were a function of x and y, when it's really a function of the parameters and one data point. The name reflected my mental model at the time, not the actual math.

What resolved it

Once I needed to sum this quantity over many points and differentiate it with respect to m and b (Stage 2–4), it became unavoidable to think of it as "error, as a function of the parameters," not "a function of x and y." The variable I differentiate with respect to defines what the function "really" depends on.

Can I derive it myself?

For the line y = 2x + 1 and the tower at (4, 6): what is the predicted ŷ? What is the residual (prediction − actual)? Is the tower above or below the line?

Active recall
Why does y = mx + b only need two numbers to be fully specified?
Because a straight line in 2D has exactly two degrees of freedom: its steepness and its vertical position. Any other line parameter (e.g. a specific point it passes through) can be derived from m and b, so no more than two numbers are needed, and no fewer will do.
Why can't a single line generally pass through 5 arbitrary points?
A line has 2 degrees of freedom (m, b); passing exactly through 5 points is 5 independent constraints. Unless the points happen to be exactly collinear, 5 constraints on 2 unknowns is over-determined — there's no solution that satisfies all of them simultaneously.
What's the difference between calling something a "function of x and y" versus "a function of the parameters"?
It's about which symbols are treated as variable and which are fixed. When x, y are one specific known tower coordinate, and m, b are what you're solving for, the expression is really a function of m and b — x, y are just constants plugged in for that one point. This distinction becomes essential the moment you differentiate, because you only differentiate with respect to the true variables.

→ Next: the line almost never fits perfectly. How do I turn "how far off" into a single number I can minimize?

2Stage 2 — How do we measure "badness"?

Error, residual, squared error, and total cost are four different things

What I knew before this stage: a line usually misses the towers. What I didn't know: how to turn "misses" into one number to minimize.

Why a line needs an objective at all

"This line looks pretty good" isn't something an algorithm can act on. I needed a single number that gets smaller as the line gets better and bigger as it gets worse, so that "improving the line" becomes "decreasing a number" — a problem calculus and search algorithms actually know how to solve.

Four words I was using loosely, made precise

  • Geometric distance / error for a point: in the facility-location framing from Stage 0, this would be the full Euclidean distance from a candidate point to a tower, √((x−h)² + (y−k)²). This is not what my code ends up computing.
  • Residual: what my code actually computes — the signed vertical gap between the line's prediction and the true value at one tower's x-position: m·tx[i] + b − ty[i]. This is exactly what fxy returns in Stage 1. It can be positive (line too high) or negative (line too low).
  • Squared error: the residual, squared: (m·tx[i] + b − ty[i])². Always non-negative — one tower's individual contribution to "badness."
  • Total cost: the sum of squared errors over every tower — one number describing how bad the whole line is, for the whole dataset, for one specific (m, b).
Preserving the Stage 0 gap

This is the same place where the "distance to a point" framing from the original problem statement and the "residual of a line" framing in the code diverge concretely. A full Euclidean distance would also penalize horizontal offset; a vertical residual doesn't care about horizontal position at all. My code always used the residual definition, everywhere, from the very first cost function onward.

The cost function, exactly as I wrote it

regression_one_tower_problem.ipynb — cell 5
# cost function

def cost(tx,ty,m,b):
    c = 0
    for i in range(0,np.size(tx)):
        c += (m*tx[i] + b - ty[i])**2

    return c
Same thing, in math notationC(m,b) = Σᵢ (m·xᵢ + b − yᵢ)²

Notice the loop: this sums one squared error per tower into a running total c. That loop is going to matter a lot later — Stage 10 is entirely about replacing it with a vectorized NumPy expression.

Why the cost becomes a function of m and b, not of x and y

Once tx and ty are fixed (they're the towers — they don't change), the only things left free to vary are m and b. So C is a function of the parameters, evaluated using the fixed data. This is the same shift in perspective from Stage 1: I stop asking "what's the residual at this x" and start asking "what's the total cost for this choice of line."

Why square the error instead of using distance directly?

This genuinely confused me for a while, and it's worth spelling out because the notebooks never resolve it explicitly — it's implicit in every cost function I wrote, always squared, never square-rooted. A few real reasons squaring wins over using the raw (or absolute) residual or a full square-root distance:

  • Sign cancellation: residuals can be positive or negative. Summing raw residuals lets errors on opposite sides of the line cancel out, making a badly-fit line look artificially good. Squaring removes the sign, so every error contributes positively.
  • Differentiability: |residual| (absolute value) also removes the sign, but it has a sharp corner at residual = 0, where the derivative doesn't exist. Squaring produces a smooth parabola with a well-defined derivative everywhere — essential once Stage 4 needs to differentiate the cost.
  • No square root needed: a full distance formula would require √(...), which is more expensive to differentiate and doesn't change where the minimum sits (minimizing squared distance and minimizing distance itself give the same optimal point, since square-root is monotonic for non-negative inputs) — so squaring is "free" simplification, not an approximation.
Confusion I actually had

I kept wondering why I didn't just use the literal distance formula everywhere, since the original problem statement (Stage 0) talks about "square of distance." The resolution: squared distance is the cost — I never needed the square root at all, because I only ever care about comparing costs to find a minimum, not about the literal distance value in the original units. Once I stopped trying to recover an actual distance number and accepted that "cost" is just "sum of squares," differentiating it stopped being confusing.

Watching cost change as m, b change

Go back to the hero widget and move the sliders — the "Total squared error (cost)" readout is C(m,b) evaluated live. Notice it never goes negative, and it has one clear region where it's smallest. That region is the subject of Stage 3.

What I actually learned — Stage 2
Concepts I can now explain
  • Residual vs. squared error vs. total cost as three distinct, increasingly aggregated quantities.
  • Why cost has to be a function of the parameters once the data is fixed.
  • Why squaring beats both raw residuals and absolute value.
What confused me

Why square instead of using the actual distance formula, given that my own problem statement mentioned distance explicitly.

What resolved it

Realizing the goal was never to recover a real distance value — it was to find where the cost is smallest. Since squaring preserves the location of the minimum and is smoother to differentiate, the square root was never actually needed.

Can I derive it myself?

For towers (2,3) and (9,5) and a line m=0.3, b=3: compute both residuals, both squared errors, and the total cost from just these two points. Then explain in one sentence why total cost can never be negative.

Active recall
Why does squared error avoid the sign-cancellation problem that raw residuals have?
A positive residual and a negative residual of equal magnitude sum to zero if left raw, hiding two genuine errors as "no error." Squaring makes both contributions positive before summing, so errors always accumulate rather than cancel.
Why is smoothness (differentiability) important enough to prefer squared error over absolute error?
Because minimizing the cost later requires computing its derivative (Stage 4) to know which direction decreases it. |x| has no defined derivative exactly at 0, creating an awkward corner right at perfect fit. x² is smooth everywhere, so gradient-based methods have a well-defined direction to follow at every point, including near the minimum.
Does minimizing squared distance give the same answer as minimizing actual distance?
Yes — for non-negative values, squaring is a strictly increasing (monotonic) transformation, so whatever point minimizes distance also minimizes squared distance, and vice versa. That's why the square root can be dropped without changing the answer, only the shape of the cost surface.
What's the actual difference between "residual" and "squared error" in one sentence?
A residual is a signed measure of how far off one prediction is (can be negative); squared error is that same quantity squared, so it's always non-negative and emphasizes larger mistakes disproportionately.

→ Next: cost is a function of two numbers, m and b. What does that function actually look like?

3Stage 3 — The cost surface

C(m,b) isn't a curve — it's a surface, and "minimum" means something specific on it

What I knew before this stage: cost is one number per (m,b) pair. What I didn't know: what the shape of "all possible costs" looks like.

From one number to a whole surface

Stage 2 gave me a way to compute C(m,b) for one specific line. But nothing stops me from computing it for every possible (m,b) — and if I do, and plot the result with m along one axis, b along another, and cost as height, I get a 3D surface. This is the object Newton's method's opening markdown cell in optimization_using_newton_method.ipynb is actually about:

Newton method work to find value where the f(x) = 0 as we need the minimum value of our function c(m,b) we require c'(m,b) = 0 because it tells the value where slope is zero — that's the point of minima or maxima

What "minimum" means on this surface

Because every term in C(m,b) = Σ(m·xᵢ + b − yᵢ)² is a square, and a sum of squares (each convex in m,b) is itself convex, the surface for enough well-spread towers is bowl-shaped: one lowest point, no other dips to get trapped in. "Minimum" means the (m,b) pair sitting at the bottom of that bowl — the line that makes total squared error as small as it can possibly be.

What it means for the gradient to be zero

At the very bottom of a bowl, the surface is momentarily flat in every direction — nudging m slightly, or b slightly, doesn't change the height to first order. That "flatness in every direction" is exactly what "gradient equals zero" captures, and it's why Stage 4's derivatives matter: the minimum is defined as the point where both partial derivatives vanish simultaneously.

A hidden case where the bowl isn't a bowl

Look closely at cell 9 of regression_one_tower_problem.ipynb: I tested gradient descent using only one tower, the point (2, 3):

x,y = symbols('x y', positive='Integer')
x = 2
y = 3
cost_exp = (m*x + b - y)**2

With only one point, C(m,b) = (2m + b − 3)² is zero along the entire line 2m + b = 3 — not at one point. That's a valley, not a bowl: infinitely many (m,b) pairs achieve the exact minimum (cost = 0), because one equation (one tower) can't pin down two unknowns (m and b). Sure enough, the notebook's gradient descent converged to (m, b) ≈ (0.4, 2.2) — check it: 2(0.4) + 2.2 = 3.0, right on that line, but not the same point I'd get starting from a different initial guess. The bowl only becomes a true single-point bowl once enough towers (with different x-values) are included to fully determine both m and b — which is exactly what the 5-tower dataset does.

Interactive · the cost surface C(m,b)
Click anywhere on the surface to drop a point. Cost at that (m,b):
low cost high cost your point
What I actually learned — Stage 3
Concepts I can now explain
  • Cost as a 3D surface over the (m,b) plane, not just a number.
  • Why squared-error costs tend to be bowl-shaped (convex).
  • Gradient = 0 as "flat in every direction" at the bottom.
  • Why one data point under-determines a 2-parameter model.
What confused me

Why the single-tower gradient descent in cell 9 always converged to some answer, but a different answer depending on the starting (m,b) — that felt like a bug at first.

What resolved it

Realizing it wasn't a bug at all — the cost surface for one point genuinely has infinitely many minima lying along a line, so gradient descent correctly finds the closest one to where it started, not "the" answer, because there isn't a unique one.

Can I derive it myself?

For a single tower (x₀,y₀), write C(m,b)=(mx₀+b−y₀)². Show algebraically that C=0 exactly along the line b = y₀ − m·x₀, confirming it's a whole line of solutions, not one point.

Active recall
Why is the cost surface for enough spread-out towers bowl-shaped rather than having multiple dips?
Each term (m·xᵢ+b−yᵢ)² is a convex function of (m,b) — a paraboloid opening upward. A sum of convex functions is convex, so the total cost has a single global minimum region with no separate local dips to get trapped in, as long as the towers aren't degenerate (e.g. all at the same x).
What does it mean geometrically for the gradient to be zero at a point on the surface?
It means the surface is momentarily flat in every direction at that point — moving a tiny step in any direction (changing m slightly, b slightly, or both) doesn't change the cost to first order. That's the defining condition of a smooth surface's minimum (or maximum, or saddle).
Why does one tower alone fail to determine a unique best line?
One tower gives one equation relating m and b (mx₀+b=y₀), but there are two unknowns. One equation in two unknowns has infinitely many solutions — a whole line in (m,b)-space — not a single point.

→ Next: to find the bottom of the bowl mathematically, I need the tool that finds where a surface is flat — differentiation.

4Stage 4 — Derivatives

Differentiation tells me which way is downhill

What I knew before this stage: the minimum is where the surface is flat. What I didn't know: how to find that point without checking every (m,b) by hand.

Why differentiation is needed

Checking every possible (m,b) pair to find the smallest cost isn't feasible — there are infinitely many. Differentiation gives something far more useful than a brute-force scan: at any single (m,b), it tells me the direction in which cost is increasing fastest. If I know which way is "uphill," I automatically know which way is "downhill," and I can move that way to make cost smaller — without ever having evaluated the whole surface.

The derivative with respect to m

Starting from C(m,b) = Σᵢ(m·xᵢ + b − yᵢ)², treat b as fixed and differentiate with respect to m using the chain rule — each term is (something)², so its derivative is 2·(something)·(derivative of something):

∂C/∂m∂C/∂m = Σᵢ 2·(m·xᵢ + b − yᵢ)·xᵢ

The xᵢ at the end comes from ∂/∂m of (m·xᵢ), which is just xᵢ. So each tower contributes its own error, scaled by its own x-position.

The derivative with respect to b

∂C/∂b∂C/∂b = Σᵢ 2·(m·xᵢ + b − yᵢ)

Same chain rule, but ∂/∂b of (b) is just 1, so no extra factor appears — every tower contributes its error unscaled.

The gradient, as a pair of partials

Gradient∇C(m,b) = [ ∂C/∂m , ∂C/∂b ]

The gradient is nothing more mysterious than "both partial derivatives, collected into one vector." Geometrically, it points in the direction of steepest ascent on the cost surface — the direction that increases C the fastest, from wherever you're currently standing. Moving in the opposite direction is therefore the direction of steepest descent — this single geometric fact is the entire justification for gradient descent in Stage 5.

My first implementation — where I actually made a mistake

regression_one_tower_problem.ipynb — cell 7
# now calculate the gradient that use for gradient descent

def gradient(m,b):
    dm = np.zeros(len(tx))
    db = np.zeros(len(tx))

    for i in range(len(tx)):
        error = m*tx[i] + b - ty[i]
        dm[i] = 2*error*tx[i]
        db[i] = 2*error

    return dm,db


dcdm , dcdb = gradient(2,3)
print(dcdm,dcdb)
[ -8. 70. 378. 126. 54.] [-4. 14. 42. 18. 18.]
Mistake — the gradient function, vs. calling the gradient function correctly

This function computes each tower's individual contribution to the gradient and stores it in an array of length 5 — but it never sums them. ∂C/∂m is supposed to be one number: the sum over all towers. What gradient(2,3) actually returns is [2·errorᵢ·xᵢ for each i] and [2·errorᵢ for each i], unsummed — you can see this directly in the printed output: five separate values, not two. This is the "gradient function vs. calling the gradient function" trap: the function looks correct (it uses the right per-term formula), but calling it and using its raw output as "the gradient" silently uses the wrong object — an array of per-point slopes instead of the one total slope.

There's also a second, quieter issue in this same notebook: the line right after it, defining gradient_descent(dcdm, dcdb, m, b, alpha, iteration), was written to take dcdm, dcdb as arguments directly — but it also contains print("m: "+ m + " b: " + b), string-concatenating numbers, which throws a TypeError the moment it actually runs. There is no output cell underneath it in the notebook — it was written but never successfully executed. It gets quietly superseded a cell later by a version that works.

The version that actually works — a single point, summed correctly

regression_one_tower_problem.ipynb — cell 9
def dfdm(a,c):
    return diff(cost_exp,m).evalf(subs={m:a,b:c})

def dfdb(a,c):
    return diff(cost_exp,b).evalf(subs={m:a,b:c})

Here I switched strategy entirely: instead of manually looping and manually differentiating by hand, I let SymPy's diff() compute the exact symbolic derivative of cost_exp, then substitute in numeric values with .evalf(subs=...). This sidesteps the "forgot to sum" mistake completely, because SymPy differentiates the already-summed cost expression — there's no per-point array to accidentally leave unsummed. This is the moment symbolic differentiation enters the journey (formalized in Stage 7).

What I actually learned — Stage 4
Concepts I can now explain
  • ∂C/∂m and ∂C/∂b via the chain rule on a sum of squares.
  • The gradient as a vector of partial derivatives pointing uphill.
  • Why "steepest descent" is just "negative gradient."
What confused me

My own gradient() function ran without error and produced numbers that looked plausible (an array of floats) — nothing about the output screamed "this is wrong," which is exactly why the missing sum was easy to miss.

What resolved it

Switching to SymPy's diff() on the whole summed cost expression, which structurally can't produce a per-point array — its output is a single expression, forcing the "one number per partial derivative" shape to be correct by construction.

Can I derive it myself?

For towers (2,3) and (4,6) only, with m=1, b=0: compute the per-point contributions to ∂C/∂m and ∂C/∂b by hand, then sum them to get the true gradient. Compare to what my buggy gradient() function above would have returned instead.

Active recall
Why is the gradient a vector rather than a single number?
Because the cost depends on two independent parameters, m and b, each of which can be nudged separately. The gradient needs one component per parameter (∂C/∂m and ∂C/∂b) to fully describe how the cost responds to a small change in any direction in that 2-dimensional parameter space.
What exactly went wrong in the first gradient() implementation?
It computed the correct per-tower contribution to each partial derivative, but returned an array of those per-tower contributions instead of summing them into two single numbers. The true ∂C/∂m and ∂C/∂b are sums over all towers, not lists of individual tower terms.
Why does using SymPy's diff() on the full summed cost expression avoid that mistake structurally?
Because diff() operates on the already-summed symbolic expression C(m,b), its result is a single symbolic expression representing the whole derivative — there's no intermediate per-point array that could be left unsummed by accident.

→ Next: with a working gradient, I can finally build an algorithm that walks downhill step by step.

5Stage 5 — Gradient descent

Walking downhill, one step at a time — and watching it go wrong in three different ways

What I knew before this stage: the negative gradient points downhill. What I didn't know: how big a step to take, or what happens when I get that wrong.

The mathematical idea

If the negative gradient points downhill from wherever I'm standing, then repeatedly taking a small step in that direction should walk me toward the minimum. That's the entire idea — everything else is bookkeeping around how big the step is and how many times I take it.

The update rule

Update rulem ← m − α·∂C/∂m b ← b − α·∂C/∂b
  • α (alpha), the learning rate: how big a step to take. Too small and progress crawls; too large and the step overshoots the minimum entirely.
  • Initial parameters: where the walk starts. Since (for a true bowl) the surface is convex, the starting point only affects how long the walk takes, not where it ends up — unlike the single-tower valley case from Stage 3, where the starting point determines which minimum is found.
  • Iterations: how many steps to take before stopping.

Why does this move toward the minimum at all? Because at every step, moving a small enough distance in the negative-gradient direction is guaranteed to decrease the cost (that's what "steepest descent direction" means, to first order). Repeating that enough times, with small enough steps, has to end up at a point where there's no more downhill left to go — the minimum.

Mistake — gradient ascent instead of descent

regression_one_tower_problem.ipynb — cell 8 (written, but never successfully run)
def gradient_descent(dcdm,dcdb,m,b,alpha=0.2,iteration=100):
    for i in range(iteration):
        m , b = m + alpha*dcdm , b + alpha*dcdb
        print("m: "+ m , " b: " + b)

    return m,b
Sign error

This adds alpha*dcdm instead of subtracting it — that's gradient ascent, moving toward higher cost, not lower. Combined with the string-concatenation bug in the print statement (which would crash immediately on real numbers), this function was never actually exercised — it's a clean example of writing the update rule with the wrong sign on the very first attempt, a mistake that's easy to make since "descent" and "the gradient" both sound like they should point the same way, when in fact descent means going against the gradient.

The corrected direction shows up immediately in the very next cell, grad_desc(dfdm,dfdb,a,c,alpha,iteration), which subtracts: a, c = a - alpha*dfdm(a,c) , c - alpha*dfdb(a,c) — this is the version that actually converges.

Experiment — overshoot as a stable oscillation

Cell 10 of the same notebook tries several learning rates on the single-tower problem starting from (m,b) = (18, 23). One of the runs produces this, forever:

m: -26.8000000000000 b: 0.599999999999998 m: 18.0000000000000 b: 23.0000000000000 m: -26.8000000000000 b: 0.599999999999998 m: 18.0000000000000 b: 23.0000000000000 ... (repeats indefinitely)

This is overshoot in its purest form: the step size is large enough that each update leaps clean over the minimum and lands on the opposite side, symmetric enough that the next update leaps right back. Instead of converging, the walk bounces between two points forever — neither approaching the minimum nor diverging to infinity, just perfectly overshooting every single time. Mathematically, this happens because for this single-point cost (a valley, from Stage 3), the update along the direction perpendicular to the valley behaves like x ← x − α·k·x for some constant k; when α·k = 2, that update becomes x ← −x — an exact flip every step, which is precisely a 2-cycle.

Experiment — real divergence

In linear_regression.ipynb, the same 5-tower dataset with a learning rate of 0.5 blows up completely within 10 iterations:

m: -348.000000000000 b:-46.0000000000000 m: 71073.0000000000 b:10300.0000000000 m: -14513161.0000000 b:-2102293.00000000 m: 2963598836.00000 b:429290865.000000 ... m: 2.14864086297759E+23 b:3.11240466507612E+22

Here α·k is greater than 2 (not just equal to it), so each step doesn't just flip sign — it flips sign and grows in magnitude. Every update makes the next update larger, and the sequence runs away to infinity exponentially fast. This is the mathematical reason "too large a learning rate" doesn't just slow convergence — past a threshold, it guarantees divergence.

Experiment — painfully slow convergence

The opposite failure shows up a few cells later: 1000 randomly-scaled towers (x up to 500), learning rate shrunk all the way to 1e-8, run for up to 1,000,000 iterations — and it still hadn't fully converged (b was creeping toward 7 at a rate of about +0.00004 per 100 iterations by the end). A learning rate small enough to avoid divergence on the large-scale feature (m) turns out to be far too small to make timely progress on the small-scale one (b). This exact experiment is what Stage 13 comes back to explain properly, once feature scale is named as the real culprit.

Interactive · gradient descent explorer
Step: 0   Cost:   Status: ready
tower current line path in (m,b) space →see mini-map

The α slider is scaled so tiny and huge learning rates are both reachable. Try α around 0.03–0.06 (converges smoothly), around 0.09–0.1 (oscillates, similar to the two-value repeat above), and above ~0.13 (diverges, similar to the 1e23 blow-up above).

What I actually learned — Stage 5
Concepts I can now explain
  • The update rule and why it subtracts (not adds) the gradient.
  • Learning rate as controlling step size, with a precise threshold between oscillation and divergence.
  • Why too-small a rate is "safe" but can be impractically slow.
What confused me

Seeing the exact same two (m,b) pairs repeat forever looked like a print-loop bug the first time I saw it, not a real property of the optimization.

What resolved it

Working out that near a quadratic minimum, gradient descent behaves like repeatedly multiplying the distance-to-minimum by a fixed factor (1 − α·k). If that factor is exactly −1, you get a perfect flip forever; if its magnitude exceeds 1, you get divergence; if its magnitude is below 1, you get convergence — one formula explains all three experiments.

Can I derive it myself?

Near a 1-D quadratic minimum, show that the update m ← m − α·C'(m) can be rewritten as (m − m*) ← (1 − α·k)·(m − m*) for the local curvature k. Explain what happens for |1−αk| < 1, = 1, and > 1.

Active recall
What happens if you add the gradient instead of subtracting it?
You get gradient ascent — every step moves toward higher cost instead of lower, so the algorithm actively climbs away from the minimum instead of toward it.
Why does a learning rate that's "just right" for oscillation produce an exact repeating cycle rather than slow drift?
When the effective per-step multiplier (1 − αk) equals exactly −1, each update flips the distance from the minimum to the exact opposite sign with the same magnitude — landing on a mirror-image point every time, forever, with no net drift in either direction.
Why does too small a learning rate not cause incorrect results, only slow ones?
As long as α is positive and small enough that (1 − αk) stays between −1 and 1, every step still moves strictly toward the minimum — it's guaranteed to converge eventually. A smaller α just means smaller steps, so more of them are needed to cover the same distance.
Why can a single learning rate be simultaneously "too small" and "too large" on the same problem?
Different parameters can have very different local curvature (k) when features are on very different scales. The threshold for stability (|1−αk|<1) depends on k, so a rate stable for a small-curvature direction can be unstable for a large-curvature one — this is exactly the feature-scaling problem developed fully in Stage 13.

→ Next: watching the numbers scroll by isn't very satisfying. What does this optimization actually look like?

6Stage 6 — Visualizing the optimization

Turning a scrolling wall of numbers into something I could actually watch happen

What I knew before this stage: gradient descent produces a sequence of (m,b) values. What I didn't know: how to see that sequence as motion.

Storing parameter history

To animate anything, I needed every intermediate (m,b), not just the final answer — so gradient_descent in regression_multi_tower_problem.ipynb started appending each step to a list:

regression_multi_tower_problem.ipynb — cell 3
def gradient_descent(dcdm,dcdb,a,c,alpha=0.1,iteration=100):
    history = []
    cost_data = []
    for i in range(iteration):
        a , c = a - alpha*dcdm(a,c) , c - alpha*dcdb(a,c)
        cos = cost.evalf(subs={m:a,b:c})

        history.append((a,c))
        cost_data.append(cos)
        print("m:",a,"b:",c,"cost:",cos)

    return a,c,history,cost_data

This version is safe without any special care: a and c are plain Python floats, and a, c = a - ... rebinds them to brand-new float objects every iteration. Appending the tuple (a,c) stores a snapshot of whatever those floats were at that moment — there's nothing left to mutate later, because floats and tuples are immutable.

Why .copy() became necessary later

By the time I wrote the LinearRegression class in linear_regression.ipynb, the parameters had become a single mutable container, m_b (a NumPy array), updated by assigning into its slots rather than rebinding the whole thing:

linear_regression.ipynb
def gradient_descent(self,m_b,alpha,iteration):
    history = []
    grad = self.gradient()

    for i in range(iteration):
        dm = grad[0].evalf(subs={self.m:m_b[0],self.b:m_b[1]})
        db = grad[1].evalf(subs={self.m:m_b[0],self.b:m_b[1]})
        m_b[0],m_b[1] = m_b[0] - alpha*dm , m_b[1] - alpha*db
        history.append(m_b.copy())
        print(f"m: {m_b[0]} b:{m_b[1]}")

    return history
Why .copy() is not optional here

m_b[0], m_b[1] = ... mutates the same array object in place — it doesn't create a new array the way a, c = a - ... did for plain floats. If history.append(m_b) had been called without .copy(), every entry in history would be a reference to that one same array. By the time the loop finishes, every single entry would show the final converged values — because there was never more than one array in memory, just one array being looked at from many list slots. .copy() forces a real, independent snapshot to be stored at each step, which is the whole point of keeping a history at all.

There's real, visible evidence in the notebook of exactly this kind of aliasing having consequences, even though the failure mode above isn't literally shown mid-crash: in cell 4, a_c is passed into l2.gradient_descent(a_c, ...), which mutates it in place and returns it, converged, around (3.02, 0.0097). Two cells later, cell 6 calls l2.gradient_optimized(a_c, 0.00000001, 1000000, 100) without redefining a_c first — and its very first printed line is already m: 3.000147... b: 6.950..., picking up mid-flight from wherever the array was left, not starting fresh from (0,0). That's the same "the array remembers what happened to it" behavior that makes .copy() necessary for history — just showing up between cells instead of inside a single loop.

The animation itself

regression_multi_tower_problem.ipynb — cell 8
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(6, 6))
ax.set_xlim(0, 10); ax.set_ylim(0, 10)
ax.scatter(tx, ty)
line, = ax.plot([], [], lw=2)
x = [0, 10]

def init():
    line.set_data([], [])
    return line,

def animate(frame):
    m = history[frame][0]
    b = history[frame][1]
    y = [m * i + b for i in x]
    line.set_data(x, y)
    return line,

anim = FuncAnimation(fig, animate, init_func=init, frames=len(history), interval=50, blit=True)
anim.save("gradientdescentline.mp4", writer="ffmpeg", fps=30)

Conceptually, FuncAnimation just calls animate(frame) repeatedly, once per frame, handing it an increasing frame number. animate looks up history[frame] — the (m,b) recorded at that exact step of gradient descent — recomputes the two endpoints of the line at x=0 and x=10, and redraws it. So each frame is literally one iteration of gradient descent: frame 0 is the starting line, frame 1 is after one update, and so on, with the towers plotted once as a fixed scatter underneath. Before committing to the full animation, cell 7 sanity-checked this by hand, manually plotting the line at a handful of chosen iterations (0, 5, 50, 100, 1000, 99999) to see it visibly walking toward the towers before generating the full video.

The cost-vs-iteration plot

iteration = [i for i in range(10000)]
plt.xlim(0,500); plt.ylim(0,2000)
plt.xlabel('iteration number'); plt.ylabel('cost')
plt.plot(iteration,cost_data)
plt.scatter(iteration,cost_data)
plt.show()

This is a different, complementary view from the line animation: instead of watching the line move in data-space, it watches the single number cost_data[i] — the height on the Stage 3 surface — fall over iterations. The two plots are two projections of the same underlying walk down the bowl.

What I actually learned — Stage 6
Concepts I can now explain
  • Why immutable values (floats, tuples) don't need .copy() but mutable containers (arrays updated in place) do.
  • What a frame of FuncAnimation actually represents here: one gradient descent iteration.
  • Cost-vs-iteration as a second, simpler view of the same optimization.
What confused me

Why the same "storing a history" pattern needed different treatment in two different notebooks — it looked like inconsistent style rather than a real, principled difference.

What resolved it

Recognizing that it comes down to rebinding vs. mutation: a, c = a - ... creates new objects every time; arr[0] = ... changes an existing object in place. Whether .copy() is needed depends entirely on which of those two things the update code does.

Can I derive it myself?

Write two 3-line Python snippets: one that updates a running (m,b) pair as plain floats and appends to a list, one that updates a NumPy array in place and appends to a list without .copy(). Predict, before running, what each list looks like at the end.

Active recall
Why doesn't `history.append((a,c))` need `.copy()` when a, c are plain floats?
Because `a, c = a - alpha*dm, c - alpha*db` creates brand-new float objects each iteration rather than modifying existing ones. The tuple appended captures those specific objects, which are never touched again afterward.
What would `history` contain if `.copy()` were removed from `history.append(m_b.copy())`?
Every entry would be a reference to the same single array object. Since that array keeps being mutated in place for every remaining iteration, by the end of the loop every entry in the history — even the ones "recorded" on iteration 1 — would show the final, fully-converged values.
In the FuncAnimation code, what does a single "frame" correspond to mathematically?
One entry of `history` — i.e., one completed iteration of gradient descent, with its own (m,b) pair and therefore its own line.

→ Next: all of this leaned on SymPy's symbolic .evalf() inside the loop. Why did that eventually need to change?

7Stage 7 — Symbolic vs. numerical computation

From "let SymPy differentiate it" to "I don't need SymPy at all anymore"

What I knew before this stage: SymPy's diff() gives an exact derivative. What I didn't know: that evaluating it repeatedly, symbolically, is slow — and eventually unnecessary.

Step 1 — pure symbolic, evaluated with .evalf() every iteration

The earliest working gradient descent (Stage 4/5) called .evalf(subs=...) inside the loop, every single iteration:

dm = diff(cost_exp,m).evalf(subs={m:a,b:c})

This works, but diff(cost_exp,m) is a symbolic SymPy expression, and .evalf(subs=...) re-walks that whole expression tree and substitutes numbers into it from scratch, every single call. For a handful of iterations on 5 towers this is invisible; for hundreds of thousands of iterations on 1000 towers, it's the main reason the slow experiments in Stage 5 and Stage 13 take so long to run.

Why a NumPy array can't just use .evalf()

SymPy's .evalf() is a method that exists on SymPy expression and number objects — it doesn't know anything about NumPy arrays, and NumPy arrays don't know how to call it element-by-element automatically. This is directly visible in optimization_using_newton_method.ipynb:

def gradient_c(a_c):
    return np.array([dm.evalf(subs={m:a_c[0],b:a_c[1]}),db.evalf(subs={m:a_c[0],b:a_c[1]})])
array([-0.00609756097560976, 4.83536585365854], dtype=object)
Symbolic vs. NumPy objects

Look at the dtype: object, not float64. .evalf() returns a SymPy Float object — a fully symbolic type, not a native Python or NumPy float. Wrapping two of them in np.array([...]) doesn't convert them; NumPy just stores the SymPy objects as-is, in a slow, generic "array of arbitrary Python objects" mode instead of a fast native numeric array. Every downstream arithmetic operation on this array falls back to SymPy's (slow) arithmetic instead of NumPy's (fast) vectorized arithmetic — invisibly. It still gives correct numbers, which is exactly why this is easy to miss: nothing crashes, it's just quietly much slower and structurally not a "real" NumPy array.

Step 2 — lambdify: compiling symbolic expressions into numeric functions

regression_multi_tower_problem.ipynb introduces the fix:

from sympy.utilities.lambdify import lambdify

dcdm_exp = diff(cost,m)
dcdb_exp = diff(cost,b)

dcdm = lambdify((m,b),dcdm_exp,'numpy')
dcdb = lambdify((m,b),dcdb_exp,'numpy')

diff() is still doing the actual calculus — nothing about the math changes. What changes is that lambdify takes the resulting symbolic expression once and compiles it into an ordinary Python function that uses plain NumPy/Python arithmetic internally. From that point on, dcdm(a,c) is just a normal function call — no expression tree walking, no substitution dictionary, no SymPy Float objects. The symbolic work happens exactly once, up front; every iteration afterward is pure numeric evaluation.

The LinearRegression class in linear_regression.ipynb makes this speed difference explicit by keeping both versions side by side: gradient_descent (still using .evalf(subs=...) every step) and gradient_optimized (using lambdify once, then calling the compiled function every step) — literally the "before" and "after" of this exact upgrade, kept in the same file.

One inconsistency worth preserving: even after discovering lambdify and using it in gradient_optimized, newton_optimization in that same class still calls .evalf(subs=...) inside its loop. The lesson from Step 1 hadn't yet been applied everywhere — a very normal, uneven kind of refactoring where a better pattern is adopted in the place it was just learned, but not yet propagated to every other function that could use it.

Step 3 — dropping symbolic computation entirely

The final version, MultiRegressionGeneral in multiple_regression.ipynb, doesn't import SymPy at all. Once the gradient of a sum-of-squares cost has a known closed form (Stage 10's 2·Xᵀe), there's no need to symbolically differentiate anything ever again — the formula can just be written directly in NumPy:

def gradient(self):
    prediction = (self.param[:-1, None] * self.data).sum(axis=0) + self.param[-1]
    error = prediction - self.output
    grad_w = 2 * (self.data @ error)
    grad_b = 2 * np.sum(error)
    return np.append(grad_w,grad_b)

This is the real end-point of Stage 7's arc, and it's a better sequence than "symbolic → lambdify → done": symbolic differentiation (SymPy) was a tool for discovering and checking the formula, not something a finished implementation needs to carry forward. Once the formula for the gradient of squared error is known and trusted, computing it is pure linear algebra — faster than even the lambdified version, because there's no compiled-expression call overhead at all, just array operations.

Symbolic vs. numeric vs. vectorized numeric, side by side

  • Symbolic representation: diff(cost,m) — an expression tree, exact, general, but not evaluated.
  • Numerical evaluation: .evalf(subs={...}) or a lambdify'd function called on scalars — one specific number, computed once.
  • Vectorized numerical computation: 2 * (self.data @ error) — every tower's contribution computed simultaneously as array operations, with no Python-level loop or per-call overhead at all (fully explored in Stage 10).
What I actually learned — Stage 7
Concepts I can now explain
  • The real cost of re-substituting into a symbolic expression on every loop iteration.
  • What lambdify actually does: compile once, call many times.
  • Why a finished implementation can drop symbolic math entirely once the formula is known.
What confused me

Why an array of SymPy Floats "worked" but felt wrong — everything computed correctly, so there was no obvious signal that dtype=object meant something was structurally off.

What resolved it

Explicitly checking .dtype on arrays that came from SymPy evaluation and noticing object instead of float64 — that one detail exposed that these weren't "real" NumPy arrays at all.

Can I derive it myself?

Explain, without running any code, why np.array([sympy_expr.evalf(), sympy_expr.evalf()]) produces a dtype=object array, and what a subsequent NumPy matrix multiplication against that array would actually be doing under the hood.

Active recall
What's the practical difference between calling .evalf(subs=...) every iteration versus using lambdify?
.evalf(subs=...) re-walks and re-substitutes into the full symbolic expression tree on every call. lambdify does that translation work exactly once, producing a plain numeric function — every subsequent call is ordinary fast arithmetic with none of the symbolic overhead.
Why does an array built from .evalf() results end up as dtype=object instead of dtype=float64?
.evalf() returns SymPy Float objects, a symbolic type NumPy doesn't recognize as native numeric data. Since NumPy can't safely convert them to float64 automatically inside np.array(...), it falls back to storing them as generic Python objects.
Why didn't the final MultiRegressionGeneral implementation need SymPy at all?
Because the gradient of a sum-of-squares cost has a known, general closed-form expression (2·Xᵀe). Once that formula is derived and trusted, there's nothing left to differentiate symbolically — computing the gradient is just evaluating that formula with NumPy.

→ Next: gradient descent only uses first-order (slope) information. What if I use curvature too?

8Stage 8 — Newton's method

Using curvature, not just slope — and reaching the minimum in one jump

What I knew before this stage: gradient descent needs many small steps. What I didn't know: a method that uses how the slope itself is changing, not just the slope.

Why gradient descent only uses first-order information

The gradient tells you the slope at exactly one point — it says nothing about whether that slope is about to get steeper or flatter as you move. Gradient descent has to find that out the hard way, by taking a step and re-measuring the slope again. Curvature — how fast the slope itself changes — is second-order information, and if it's known, it can be used to jump much more directly toward the minimum instead of repeatedly re-measuring and re-stepping.

The Hessian: curvature as a matrix

For a function of two parameters, there isn't just one curvature — there's how ∂C/∂m changes as m changes, how it changes as b changes, and the same again for ∂C/∂b. That's four numbers (two of which turn out equal), naturally arranged as a matrix:

HessianH = [ ∂²C/∂m²   ∂²C/∂m∂b ;   ∂²C/∂b∂m   ∂²C/∂b² ]

It's a matrix (not a vector, like the gradient) precisely because curvature is inherently about pairs of directions — how the slope in one direction changes as you move in another — and with n parameters there are n×n such pairings.

My implementation, from the Newton notebook

optimization_using_newton_method.ipynb
def gradient(cost):
    dm = diff(cost,m)
    db = diff(cost,b)
    return dm,db

def hessian(dm,db):
    dmb = diff(dm,b)
    dmm = diff(dm,m)
    dbm = diff(db,m)
    dbb = diff(db,b)
    return np.array([[dmm,dmb],[dbm,dbb]],dtype=float)

Note dtype=float here — this only works because dmm, dmb, dbm, dbb come out as constants with no remaining m or b symbols in them. That's not a coincidence; it's the whole reason Newton behaves so well on this problem (see below).

The Newton update

Newton's update ruleθ_new = θ − H⁻¹ ∇C(θ)    where θ = [m, b]
def newton_optimization(a_c,grad,hess,iteration):
    for i in range(iteration):
        a_c = a_c - np.matmul(np.linalg.inv(hess),grad(a_c))
        print("m:",a_c[0],"b:",a_c[1])
    return a_c
m: -0.00609756097560954 b: 4.83536585365854 m: -0.00609756097560976 b: 4.83536585365854 m: -0.00609756097560976 b: 4.83536585365854 ... (identical for all 10 iterations)

Every remaining iteration after the first prints the exact same (m,b) — Newton's method reached the minimum on its very first step and then just kept confirming it was already there.

Gradient, Hessian, inverse Hessian, and the Newton step — what each one is

  • Gradient (∇C): a vector, direction of steepest ascent. Shape: (n,) for n parameters.
  • Hessian (H): a matrix of second derivatives, describing curvature in every pair of directions. Shape: (n,n).
  • Inverse Hessian (H⁻¹): "undoes" the curvature — it rescales the gradient direction by how flat or steep the surface actually is in each direction, instead of taking a fixed-size step regardless of curvature.
  • Newton step (H⁻¹∇C): the actual displacement applied to θ. Geometrically, it's "the gradient direction, corrected for how the surface curves," rather than "the gradient direction, scaled by a fixed learning rate α."

Why Newton reaches the optimum in essentially one step here

This only happens because of a specific chain of facts about squared-error regression, worth spelling out in full:

  1. The objective is quadratic in the parameters: C(m,b) = Σ(mxᵢ+b−yᵢ)² expands into terms with m², b², and mb — a quadratic form, nothing higher-order.
  2. Its gradient is therefore linear in the parameters: differentiating a quadratic once always produces something linear (compare: d/dx(x²) = 2x, linear in x).
  3. Its Hessian is therefore constant: differentiating something linear once more produces a constant — no m or b left in it at all, which is exactly why dtype=float worked directly on hessian(dm,db) above.
  4. Newton can jump directly to the optimum: Newton's update is exact whenever the true function is exactly quadratic, because a quadratic function is fully described by its value, gradient, and (constant) curvature at any single point — Newton's step effectively solves "where would this exact quadratic hit zero gradient" directly, in one linear-algebra step, rather than approximating that answer iteratively.

This generalizes cleanly, as confirmed independently in multiple_regression.ipynb's MultipleRegression.newton_method, which reaches the known parameters [2, 3, 4] starting from [0, 0, 0] immediately, then repeats that answer for every further iteration — the exact same one-step behavior, now with three parameters instead of two. My own comment in that notebook records the insight directly: for a quadratic objective, Newton's method reaches the target in one iteration no matter how many parameters are involved, because the Hessian stays constant regardless of how many dimensions θ has.

Interactive · Newton vs. gradient descent
Newton reached the minimum in step(s). Gradient descent used steps to get within the same tolerance.
Newton's path Gradient descent's path

Extension — a numerical improvement I didn't implement

Extension / next concept — not in my notebooks

My code always calls np.linalg.inv(hess) explicitly and then multiplies. In practice, explicitly inverting a matrix is more numerically expensive and less numerically stable than solving the linear system H·Δθ = ∇C directly for Δθ (e.g. with np.linalg.solve(hess, grad)), since computing a full inverse does strictly more work than is needed just to get one particular product. For a 2×2 or 3×3 Hessian like mine, this difference is invisible; it starts to matter for larger parameter counts. I'm flagging this as an improvement I could make, not something I actually did.

What I actually learned — Stage 8
Concepts I can now explain
  • The Hessian as a matrix of second partial derivatives, and why it's a matrix rather than a vector.
  • The Newton update θ − H⁻¹∇C and what each symbol contributes.
  • Why squared-error regression makes Newton's method converge in one step, from first principles.
What confused me

Seeing the exact same output repeated for every remaining iteration looked like the loop had silently stopped doing anything, the same surprise as the gradient-descent oscillation in Stage 5, but for the opposite reason.

What resolved it

Working through why a quadratic function's Hessian has to be constant — once that clicked, "one step and done" stopped looking like a coincidence or a bug and started looking like the guaranteed, provable outcome for this specific class of cost function.

Can I derive it myself?

Starting from C(m)=am²+bm+c (a 1-parameter quadratic), compute C'(m) and C''(m), then show that the Newton update m − C'(m)/C''(m) lands exactly on the true minimum −b/2a regardless of the starting m.

Active recall
Why does Newton's method use a Hessian at all, when gradient descent doesn't need one?
Gradient descent only asks "which direction is downhill," which the gradient alone answers. Newton's method asks "how far should I step in that direction to actually land at the minimum," which requires knowing how the slope itself is curving — that's precisely what the Hessian encodes.
Why can Newton's method reach the optimum in one step specifically for least-squares regression?
Because the least-squares cost is exactly quadratic in the parameters, its gradient is exactly linear, and therefore its Hessian is exactly constant everywhere. Newton's method is exact whenever the true objective is exactly quadratic, since a quadratic is fully characterized by its gradient and (constant) curvature at any single point.
What's the practical difference between the gradient, the Hessian, and the Newton step?
The gradient is a direction (a vector). The Hessian is curvature information (a matrix). The Newton step, H⁻¹∇C, combines them into an actual displacement — the gradient direction, rescaled per-direction according to how sharply the surface curves there.
Would Newton's method still converge in one step for a cost function that wasn't quadratic?
No — Newton's method would still typically converge fast, but not exactly in one step, because the Hessian would no longer be constant. Each step would only be exact for a local quadratic approximation of the true (non-quadratic) surface, so several iterations would usually be needed.

→ Next: everything so far used one feature, x. What changes when a tower — or any data point — has more than one number describing it?

9Stage 9 — From one feature to multiple features

y = mx + b becomes y = w₁x₁ + w₂x₂ + ... + wₙxₙ + b

What I knew before this stage: one input, one output, two parameters. What I didn't know: how the same idea scales when a tower is described by more than one number.

Why one feature stopped being enough

Every tower so far had exactly one x-value. But in multiple_regression.ipynb, I explicitly moved to a setting with two inputs per sample — described in my own comment as "two dependent variable distance D and price/unit P" — and asked whether an output could be predicted from both of them at once, not just one. That requires more than one slope: one weight per input feature, plus a single shared bias.

From one feature...ŷ = m·x + b
...to manyŷ = w₁x₁ + w₂x₂ + ... + wₙxₙ + b

Vocabulary, precisely

  • Feature: one measured input quantity (e.g. distance, or price-per-unit). Each sample has n of them.
  • Sample: one complete data point — a full set of feature values, paired with one target value. In the tower context, one tower's full description.
  • Target / output (y): the value being predicted for a sample.
  • Parameter / weight (wⱼ): how much feature j contributes to the prediction, per unit of that feature — the direct generalization of "m".
  • Bias (b): the same role as before — a constant baseline added regardless of feature values.

Matrix dimensions — the part I have to get exactly right

This is the part of the notebooks where I actually used two different data orientations in two different classes, and I'm not silently picking one — both are real, both appear in my code, and knowing which is which matters.

Two orientations, both from my own code

MultipleRegression (the first, loop-based, SymPy-driven class) stores data as samples × features: each row is one full sample, with the target tacked on as the last column. Its test data literally looks like [[1,2,0],[1,3,0],[3,7,0],...] — each inner list is one tower: [x1, x2, y].

MultiRegressionGeneral (the later, vectorized NumPy class) stores data as features × samples: each row is one entire feature's values across every sample. Its test data looks like data = [distance, price] — two rows, each a full array of that one feature across all towers.

MultipleRegression — samples × features orientation
w1,w2,b = symbols('w1 w2 b')
param = [w1,w2,b]
data = [[1,2,0],[1,3,0],[3,7,0],[6,4,0],[4,9,0],[10,2,0],[12,15,0]]
# data[i]      -> one full sample: [x1, x2, y]
# data[i][j]   -> feature j of sample i, for j in 0..1
MultiRegressionGeneral — features × samples orientation
distance = np.array([1,3,5,10,8,11,16,21,34,2,0], dtype=float)
price    = np.array([12,3,6,8,9,0,5,6,14,1,56], dtype=float)
data = [distance, price]
# data[0]      -> the ENTIRE distance feature, across all samples
# data[1]      -> the ENTIRE price feature, across all samples

Neither orientation is "wrong" — plenty of real ML code uses samples × features (it's the more common convention, e.g. scikit-learn's X), and features × samples is also legitimate and, as Stage 10 shows, made my particular vectorized gradient formula (data @ error) fall out very naturally. What matters is being explicit about which one a given piece of code assumes, since the two are not interchangeable without transposing.

Interactive · dimension explorer

Why the same gradient-descent algorithm can work for any number of features

Nothing about the update rule θ ← θ − α·∇C(θ) cares how many entries θ has. Whether θ = [m, b] (2 entries) or θ = [w₁,...,wₙ,b] (n+1 entries), it's still "subtract a scaled gradient from a parameter vector." Stage 10 and 11 show exactly how the loop-based version generalizes into this shape.

What I actually learned — Stage 9
Concepts I can now explain
  • Feature, sample, target, weight, and bias as distinct roles.
  • Two valid but incompatible data orientations, and why transposition matters between them.
  • Why the parameter vector, not the algorithm, is what has to grow with more features.
What confused me

Moving between MultipleRegression and MultiRegressionGeneral and expecting self.data[i] to mean the same thing in both — it doesn't, and mixing them up would silently compute the wrong thing without erroring.

What resolved it

Explicitly writing out what a single row means in each class before touching the math — "row = one sample" vs. "row = one feature" — rather than assuming a shared convention across files.

Can I derive it myself?

Given 3 samples and 2 features, write out the data array explicitly in both samples×features and features×samples form, and show that one is exactly the transpose of the other.

Active recall
What is a "sample" versus a "feature"?
A sample is one complete data point — everything known about one tower. A feature is one specific measured quantity (e.g. distance) that appears across every sample. A dataset of m towers each described by n numbers has m samples and n features.
Why does it matter whether data is stored as samples×features or features×samples?
The two layouts are transposes of each other, and matrix operations (like `data @ error`) give completely different, dimensionally-incompatible or silently-wrong results if the code assumes the wrong one. Getting predictions and gradients right requires knowing exactly which axis represents samples and which represents features.
Why doesn't gradient descent itself need to change when the number of features changes?
The update rule only operates on "the parameter vector" and "its gradient," treating both as generic vectors of whatever length they happen to be. Adding features just makes those vectors longer — the subtraction and scaling logic is unchanged.

→ Next: with n features, per-point Python loops start to look expensive. What does the same math look like as matrix operations?

10Stage 10 — Vectorization

Every Python for-loop over samples is secretly a matrix operation waiting to happen

What I knew before this stage: for-loops over towers compute the right numbers. What I didn't know: that the same numbers come out of a couple of matrix operations, much faster.

Starting point — the loop-based cost

def cost(self):
    c = 0
    for i in range(len(self.data)):
        gather = 0
        for j in range(len(self.param)-1):
            gather += self.param[j]*self.data[i][j]
        c += (gather + self.param[-1] - self.data[i][-1])**2
    return c

Two nested loops: the outer one walks samples, the inner one walks features within a sample, manually accumulating gather = Σⱼ wⱼxᵢⱼ before adding the bias and squaring the error. Every single multiplication and addition here has a direct matrix-operation counterpart.

The same calculation as matrix operations

def cost(self):
    prediction = (self.param[:-1, None] * self.data).sum(axis=0) + self.param[-1]
    error = prediction - self.output
    return np.sum(error**2)

Reading this line by line, against the loop it replaces:

  • self.param[:-1, None] — reshapes the weight vector from shape (n,) to (n,1) so it can be broadcast against every sample's column at once. This is broadcasting: NumPy stretches the (n,1) array across the sample axis without actually copying it n times.
  • self.param[:-1, None] * self.data — an element-wise multiplication (not matrix multiplication): for a features×samples-shaped self.data, this multiplies every feature's row by its own weight, for every sample simultaneously. This replaces the inner loop's self.param[j]*self.data[i][j], done for every (i,j) pair at once instead of one at a time.
  • .sum(axis=0) — sums down each column (over the features axis, axis 0, in a features×samples array), collapsing n feature-contributions into one prediction per sample. This is exactly the inner loop's gather accumulator, computed for every sample in one call instead of looped.
  • + self.param[-1] — adds the bias to every sample's prediction at once (broadcasting a single scalar across the whole array).
  • error**2 then np.sum(...) — squares every residual element-wise, then adds them all up. This replaces the outer loop's running total c.

The gradient — where @ (matrix multiplication / dot product) enters

def gradient(self):
    prediction = (self.param[:-1, None] * self.data).sum(axis=0) + self.param[-1]
    error = prediction - self.output
    grad_w = 2 * (self.data @ error)
    grad_b = 2 * np.sum(error)
    return np.append(grad_w,grad_b)

self.data @ error is a matrix-vector product. Because self.data is features×samples (shape (n, m)) and error is (m,), the product has shape (n,) — one number per feature. What does that number mean? Row j of self.data is feature j's value across every sample; taking its dot product with error computes exactly Σᵢ xⱼᵢ · errorᵢ — which is precisely ∂C/∂wⱼ (up to the factor of 2), for every feature j, simultaneously. This single @ replaces an entire loop over both samples and features.

data @ error vs. data.T @ error — why the transpose flips depending on orientation

This only comes out as data @ error (not data.T @ error) because self.data is features×samples here (Stage 9). If self.data were instead samples×features (shape (m,n), one row per sample — like MultipleRegression's orientation), the matching operation would have to be data.T @ error instead, since you'd need to transpose it first to get features back onto the rows before dotting each feature's column against the sample-indexed error vector. The correct choice between data @ error and data.T @ error isn't arbitrary — it's forced by whichever orientation the data is actually stored in.

Vocabulary, tied directly to the code above

  • Element-wise multiplication (*): multiplies matching positions independently; shapes must match or broadcast.
  • Broadcasting: NumPy's rule for stretching smaller-shaped arrays across a larger one without copying data, e.g. turning a (n,1) weight column into an effective (n,m) grid to multiply against (n,m) data.
  • Matrix multiplication / dot product (@): sums products across a shared axis — turns "multiply then sum" (two separate loop-conceptual steps) into a single operation.
  • np.sum(..., axis=...): collapses one specific axis by summation; axis=0 sums down columns, axis=1 sums across rows — getting this backwards silently produces a result with the wrong shape or the wrong meaning.
  • @ vs. plain np.sum: @ is "multiply corresponding entries, then sum" fused into one call; when a computation is exactly of that shape, @ replaces both a multiplication and a subsequent sum.

Why vectorization is faster

The loop version pays Python's per-iteration overhead (bytecode dispatch, bounds checks, object creation for each intermediate float) once for every single sample-feature pair. The vectorized version dispatches into NumPy's compiled C loops once for the whole array, regardless of how many samples or features there are — the actual arithmetic is comparable, but the overhead around each individual operation collapses from "once per number" to "once total." This is a big part of why the slow 1,000,000-iteration experiments in Stage 5 and Stage 13 were even feasible to run at all once the code moved to this form.

How the feature count generalizes for free

Nothing above hard-codes "2 features." self.param[:-1, None] * self.data works whether self.data has 2 rows or 20 — the shapes just have to agree. This is the concrete mechanism behind Stage 9's claim that the same algorithm handles any number of features: the loop bounds that used to be explicit (range(len(self.param)-1)) are replaced by array shapes that NumPy checks automatically.

Interactive · loop vs. vectorized, side by side
Running total (Σ xᵢ·errorᵢ, i.e. half of grad_w): 0.00
currently processed by the loop processed simultaneously by @
What I actually learned — Stage 10
Concepts I can now explain
  • Every term in a vectorized formula traced back to the specific loop step it replaces.
  • Broadcasting as "implicit stretching," not literal copying.
  • axis=0 vs axis=1, and how @ fuses multiply-then-sum.
What confused me

Why data @ error was correct in one class but would have needed .T in the other — it looked like an arbitrary detail until I connected it back to which axis was samples and which was features.

What resolved it

Working out the shapes explicitly on paper before trusting the code: (n,m) @ (m,) only works and only means the right thing if the m-axis genuinely is the sample axis in that particular array.

Can I derive it myself?

For 2 features and 4 samples, write the nested-loop computation of gather for sample i=2 by hand, then show it equals the i=2 entry of (param[:-1,None]*data).sum(axis=0) computed the vectorized way.

Active recall
Why does vectorization improve performance if the total number of multiplications is the same?
Because most of the cost in a Python loop isn't the arithmetic itself — it's the per-iteration interpreter overhead (bytecode dispatch, temporary object creation, bounds checks). Vectorized NumPy operations do the same arithmetic inside compiled C loops with that overhead paid once for the whole array, not once per element.
Why does data @ error need to become data.T @ error if the data orientation changes?
Matrix multiplication requires the inner dimensions to match up meaningfully — specifically, the axis being summed over must be the sample axis. If data switches from features×samples to samples×features, the sample axis moves from columns to rows, so a transpose is needed to put the sample axis back where the multiplication expects it.
What does axis=0 mean when calling .sum(axis=0) on a features×samples array?
It sums down each column — i.e., across the rows, which are the features in this orientation — collapsing all feature contributions into a single value per sample (per column).

→ Next: with vectorized operations and n features in hand, what does the fully general implementation look like?

11Stage 11 — Generalized multiple regression

From hard-coded w1, w2, b to a parameter vector of any length

What I knew before this stage: two named weights plus a bias, written by hand. What I didn't know: how to stop hard-coding the parameter count entirely.

Where I started — named parameters

w1,w2,b = symbols('w1 w2 b')
param = [w1,w2,b]

MultipleRegression works, but every one of its three parameters exists because I typed its name. Adding a third feature would mean editing the class by hand — adding a w3 symbol, extending the inner loop's range, and so on. That's fine for exploring the idea with 2 features, but it isn't a general implementation yet — it's a specific one that happens to have 2 features baked in structurally (even though the loop itself, for j in range(len(self.param)-1), was already written generically).

Where I ended up — an arbitrary-length parameter vector

class MultiRegressionGeneral:
    def __init__(self,data,output,param):
        self.data = np.asarray(data, dtype=float)
        self.output = np.asarray(output, dtype=float)
        self.param = np.asarray(param, dtype=float)

param is no longer a hand-written list of named SymPy symbols — it's a plain NumPy array, and nothing in the class cares how long it is. The transition looks like this:

Named, fixed-size[w1, w2, b]   — exactly 3 entries, by construction
Generalized[w1, w2, ..., wₙ, b]   — n+1 entries, n decided at call time

This is visible directly in how differently the class gets used across two experiments in the same notebook. First, with 2 features:

data = [distance, price]
param = np.array([2,3,4],dtype=float)
m = MultiRegressionGeneral(data,output,param)

Then, without a single change to the class itself, with 5 features:

data = [distance, price, area, age, rooms]
output = 2*distance + 3*price + 0.5*area - 4*age + 10*rooms + 20
param = np.zeros(6)   # 5 weights + 1 bias — the "6" is the only thing that changed
m2 = MultiRegressionGeneral(data,output,param)

The only number that changed between a 2-feature and a 5-feature regression is the length of param (and, correspondingly, the number of rows in data). No method inside the class needed editing.

Why the same gradient-descent algorithm still works

Trace it through: self.param[:-1, None] * self.data broadcasts across however many feature-rows exist; .sum(axis=0) collapses however many there are; self.data @ error produces a gradient vector exactly as long as the number of feature-rows in self.data. Every shape in the computation is derived from the shapes of self.data and self.param, never hard-coded — which is precisely what makes "generalized" true in a structural sense, not just in the sense of "happens to work for the numbers I tried."

What I actually learned — Stage 11
Concepts I can now explain
  • The difference between code that "works for n=2" and code that is structurally general in n.
  • How array shapes, not explicit loop bounds, are what make NumPy code generalize automatically.
What confused me

Whether "generalizing" the class meant rewriting the math — it didn't. The math (sum of squared errors, its gradient) was identical; only the data structures needed to stop assuming a fixed count.

What resolved it

Seeing that going from 2 features to 5 required changing exactly one number (np.zeros(6) instead of np.array([2,3,4])) and the shape of the input data — nothing inside MultiRegressionGeneral itself.

Can I derive it myself?

Without changing any method body, describe exactly what two things would need to be passed differently to run MultiRegressionGeneral on a dataset with 10 features instead of 5.

Active recall
What concretely changed between the 2-feature and 5-feature experiments in MultiRegressionGeneral?
Only the data passed in (how many feature arrays are in the `data` list) and the length of the initial `param` array (`np.zeros(6)` instead of a 3-element array). No method inside the class was modified.
Why is the earlier MultipleRegression class less "general" even though its inner loop already used range(len(self.param)-1)?
Because its parameters were still individually declared, named SymPy symbols (`w1,w2,b`) constructed by hand for a specific count — adding a feature meant editing the source to add another symbol, even if the loop body itself didn't need to change.

→ Next: a general implementation is only useful if I can trust it. How did I actually verify it was correct?

12Stage 12 — Testing the implementation

If I already know the answer, my optimizer had better find it

What I knew before this stage: the code runs without crashing. What I didn't know: whether it's actually computing the right thing.

The debugging trick — generate data from a known equation

A regression implementation can run cleanly, produce plausible-looking numbers, and still be subtly wrong — the one_tower gradient bug in Stage 4 proved that. The fix I used throughout multiple_regression.ipynb: instead of using real, noisy, unknown data, generate data from a known formula, and check whether the optimizer can find that exact formula back out.

def cost(x1,x2):
    return 2*x1 + 3*x2 + 4

data = [[1,2,0],[1,3,0],[3,7,0],[6,4,0],[4,9,0],[10,2,0],[12,15,0]]
for i in range(len(data)):
    data[i][2] = cost(data[i][0],data[i][1])   # fill in y using the KNOWN true formula

Every target value here comes from a formula I chose myself: y = 2x₁ + 3x₂ + 4. So the "correct" parameters are known in advance to be exactly [2, 3, 4] — I'm not hoping the optimizer finds a good fit, I'm checking whether it finds the fit I built the data to have.

Known parameters → generate y → initialize wrong → optimize → compare

opt = np.array([0,0,0],dtype=float)   # deliberately wrong: nowhere near [2,3,4]
m.gradient_descent(opt,0.001,10000)
[3.24 3.808 0.456] [2.27872 2.769408 0.345984] [2.50988173 3.0958569 0.39588467] ...

Starting from [0,0,0], gradient descent visibly drifts toward the true [2,3,4] — the sequence of printed vectors is the tell: if the implementation were wrong, it would converge to some other triplet, or not converge at all, or (as in Stage 5's oscillation and divergence experiments) never settle down. Newton's method, tested the same way, jumps straight to [2. 3. 4.] on the very first iteration — independent confirmation from a completely different method, which matters, since if both a slow iterative method and an exact one-step method agree on the same answer, that's much stronger evidence than either alone.

This exact same test was repeated at larger scale with MultiRegressionGeneral: a known formula cost(x1,x2) = 2·x1 + 3·x2 + 4, applied to 11 synthetic distance/price samples, recovered as array([2.00000007, 3.00000004, 3.99999839]) after 200,000 iterations — accurate to about 6 decimal places, which is exactly what I'd expect from a converging-but-not-yet-perfectly-converged iterative method.

The stronger verification: correct parameters ⇒ cost = 0 and gradient = 0

Recovering approximately the right answer after optimizing is good evidence, but there's a sharper, more direct test that doesn't even require running the optimizer: if the parameters are exactly right, the cost function should evaluate to exactly zero (every prediction matches every target exactly), and the gradient should evaluate to exactly the zero vector (there's no downhill direction left, because you're already at the minimum). I checked both, directly:

print("Current:")
print(m2.param)
print("Cost:", m2.cost())
print("Gradient:", m2.gradient())

true_param2 = np.array([2., 3., 0.5, -4., 10., 20.])
m2.param = true_param2

print("\nTrue parameters:")
print(m2.param)
print("Cost:", m2.cost())
print("Gradient:", m2.gradient())
Current: [ 0.05412667 2.95246272 0.88207614 -4.07354718 0.77780909 0.15921005] Cost: 48.75659998513062 Gradient: [-0.16153722 -0.25153687 0.16359338 -0.3681324 -5.64263654 -2.27107187] True parameters: [ 2. 3. 0.5 -4. 10. 20. ] Cost: 0.0 Gradient: [0. 0. 0. 0. 0. 0.]

This is exactly the confirmation I wanted: plugging in parameters that hadn't yet converged gives a non-zero cost and a non-zero gradient — there's still room to improve, and the gradient correctly points toward that room. Plugging in the exact known true parameters gives cost exactly 0.0 and gradient exactly the zero vector, with no rounding residue at all. That's strong evidence the cost and gradient formulas are mathematically consistent with each other — the gradient really is the derivative of the cost I implemented, not a plausible-looking but subtly disconnected formula.

Why this kind of test matters more than it looks like it does

It's tempting to trust code because it runs and produces numbers in a reasonable range — the Stage 4 gradient bug did exactly that. A synthetic test with a known answer converts "does this look plausible?" into "does this match a number I can check exactly?" — turning debugging from inspection into verification.

What I actually learned — Stage 12
Concepts I can now explain
  • Why synthetic data with a known generating formula is a stronger test than real, unlabeled data.
  • Why cost=0 and gradient=0 at the true parameters is a sharper check than "converged to approximately the right answer."
  • Why agreement between two independent methods (gradient descent and Newton) is stronger evidence than either alone.
What confused me

Early on I judged correctness mostly by whether numbers "looked reasonable" — which is exactly the standard the Stage 4 bug slipped past.

What resolved it

Deliberately constructing a case where I already knew the exact right answer, so "close to right" versus "exactly right" became something I could actually check numerically instead of eyeballing.

Can I derive it myself?

Explain, in one or two sentences, why C(θ) = 0 at θ = true parameters necessarily implies ∇C(θ) = 0 there too, for a sum-of-squared-errors cost specifically (hint: think about what each squared term, and its derivative, must individually be at that point).

Active recall
Why is testing with synthetic, known-formula data more useful for debugging than testing with real data?
With synthetic data you know the exact correct parameters in advance, so you can check whether the optimizer converges to precisely that answer. With real data you never know the true underlying relationship (if one even exists), so you can't distinguish "the implementation is correct but the data is noisy" from "the implementation has a bug."
Why does plugging in the true parameters and getting gradient = 0 exactly (not approximately) matter?
It confirms the gradient formula implemented is mathematically the true derivative of the cost formula implemented — if they were inconsistent (e.g. one had a bug like the Stage 4 unsummed gradient), evaluating at the exact minimum of the cost would generally NOT produce a zero gradient, since a wrong gradient formula has no reason to vanish at the true cost minimum.
Why is it stronger evidence that both gradient descent and Newton's method converge to the same [2,3,4]?
The two methods are implemented differently and use different information (first-order vs. second-order). If there were a bug specific to one implementation, it would likely produce a different wrong answer than the other — so independent agreement between them makes a shared implementation bug much less likely.

→ Next: the implementation is verified correct. So why did it get so slow on realistic, differently-scaled features?

13Stage 13 — Why it got slow

One learning rate, two very different curvatures

What I knew before this stage: gradient descent can oscillate, diverge, or crawl. What I didn't know: why the exact same algorithm could be both too aggressive and too timid on the exact same run.

Coming back to the experiment that didn't add up

Stage 5 ended with an experiment I described but didn't explain: 1000 towers, x-values scaled up to 500, a learning rate shrunk all the way to 1e-8 just to keep things stable, run for 1,000,000 iterations — and b was still creeping toward its true value at roughly +0.00004 per 100 iterations at the end. Nothing about that run was broken. Every single update was a legitimate step downhill. It was just almost unusably slow. That's a different failure mode from oscillation or divergence, and it deserves its own explanation.

Revisiting the per-parameter curvature from Stage 8

Stage 8 already derived the exact Hessian for this cost function, without naming why it mattered yet:

Hessian, from Stage 8H = [ 2Σxᵢ²   2Σxᵢ ;   2Σxᵢ   2n ]

The diagonal entries are the closest thing to "curvature along m alone" and "curvature along b alone": roughly k_m ≈ 2Σxᵢ² and k_b ≈ 2n (this is an approximation — the true axes of the bowl are rotated whenever Σxᵢ ≠ 0, but it's close enough to see the point). Stage 5's own derivation already showed that gradient descent's stability condition is |1 − α·k| < 1, i.e. roughly 0 < α < 2/k, for whichever curvature k is in play.

Why one α can't serve both parameters at once

Plug in the 1000-tower experiment's shape: with x-values up to 500, Σxᵢ² is enormous — on the order of tens of millions — so k_m is huge, and the stable range for updating m alone is a razor-thin 0 < α < 2/k_m, forcing α down into the 1e-8 territory just to avoid the divergence pattern from Stage 5. But k_b = 2n = 2000 is comparatively tiny — the stable range for updating b alone would happily allow an α many orders of magnitude larger. Gradient descent only has one α to share between both updates. Set small enough to keep m stable, that same α makes every single b-update absurdly small too — even though b itself would have been perfectly happy converging fast. The whole optimization is throttled to the pace of its worst-conditioned direction.

The general name for this

This ratio — k_m / k_b, the largest curvature divided by the smallest — is what's usually called the condition number of the problem. A well-conditioned bowl (all curvatures similar) lets gradient descent converge quickly with a single, reasonably large α. A badly-conditioned bowl — exactly what happens whenever raw feature values sit on very different scales, like "x up to 500" versus "a bias that only needs to move by single digits" — forces gradient descent into the trap above: too slow everywhere, or unstable somewhere.

Why Newton's method never had this problem

This is the payoff of Stage 8's H⁻¹∇C update, stated plainly: multiplying by the inverse Hessian rescales each direction by its own curvature before stepping. A huge k_m and a tiny k_b get corrected for automatically and separately — Newton's method doesn't share one global step size across mismatched directions the way gradient descent's scalar α does. That's precisely why Newton converged in a single step regardless of how the 5-tower or 1000-tower data happened to be scaled, while gradient descent's single α had to compromise between them.

The standard fix I didn't implement: feature scaling

Extension / next concept — not in my notebooks

The conventional response to this exact problem is feature scaling (also called normalization or standardization): rescale every feature before optimizing, typically by subtracting its mean and dividing by its standard deviation, so every feature sits on a comparable numeric scale before gradient descent ever sees it. That doesn't change the underlying relationship being learned — it changes the shape of the cost surface so its curvature is roughly the same in every direction, which is exactly what makes a single, larger, stable α possible again. None of my notebooks implement this; the 1000-tower experiment is a direct, lived demonstration of the exact problem feature scaling exists to solve, discovered the hard way rather than avoided in advance.

The other way out: not iterating at all

There's a second, completely different escape from this whole problem, and I'd already built half of it without naming it: the hero widget's "Snap to best fit" button doesn't run gradient descent — it solves for the minimizing (m,b) directly, algebraically, using the same closed-form normal equations that make ordinary least-squares a solved problem: θ = (XᵀX)⁻¹Xᵀy. This is the same underlying idea as Newton's method — using the known, constant Hessian to jump straight to the answer — just phrased as "solve a linear system" instead of "take one optimizer step." It sidesteps learning rates and condition numbers entirely, at the cost of an explicit matrix inversion that gets expensive once the number of features grows large — which is exactly why iterative methods like gradient descent remain the practical choice once n is large, even though they're the ones sensitive to conditioning.

What I actually learned — Stage 13
Concepts I can now explain
  • Per-parameter curvature, and why a shared learning rate has to compromise between them.
  • Condition number as "ratio of worst to best curvature," and why large feature scales worsen it.
  • Why Newton's per-direction rescaling makes it immune to a problem gradient descent can't escape.
What confused me

I initially read the slow 1000-tower run as "gradient descent is just intrinsically slow," rather than as a symptom of a specific, fixable mismatch between how m and b individually respond to updates.

What resolved it

Going back to the Hessian I'd already derived in Stage 8 for an unrelated reason (one-step Newton convergence) and realizing its diagonal entries were the missing explanation the whole time — I had the tool before I knew I needed it.

Can I derive it myself?

For a toy 1-feature dataset where x ranges up to 1000 instead of up to 10, estimate how much smaller the stable learning rate for m has to become compared to the 5-tower dataset, using k_m ≈ 2Σxᵢ². (Hint: curvature scales with x², so a 100× larger x range means roughly a 10,000× larger k_m, and therefore a roughly 10,000× smaller safe α.)

Active recall
Why does a single learning rate struggle when features are on very different scales?
Each parameter has its own effective curvature (from the Hessian), and gradient descent's stability condition |1−αk|<1 depends on that curvature. A learning rate small enough to stay stable for a high-curvature parameter (large feature scale) is far smaller than necessary for a low-curvature parameter, so the low-curvature parameter is forced to converge unnecessarily slowly.
What is the "condition number" of an optimization problem, informally?
Roughly, the ratio between the largest and smallest curvature across all parameter directions. A high condition number means some directions are much more sensitive to step size than others, which is exactly what makes a single shared learning rate hard to choose well.
Why doesn't Newton's method suffer from poor conditioning the way gradient descent does?
Newton's update multiplies the gradient by the inverse Hessian, which rescales every direction by its own individual curvature before stepping. Gradient descent instead scales every direction by the same single α, so it has no way to compensate when different directions need very different effective step sizes.
Besides feature scaling, what's a completely different way to sidestep the condition-number problem for linear regression specifically?
Solve for the parameters directly with the closed-form normal equations, θ = (XᵀX)⁻¹Xᵀy, rather than iterating at all. Since this uses the exact (constant) curvature information directly, like Newton's method, it has no learning rate and no conditioning problem — though it requires a matrix inversion that becomes expensive as the number of features grows.

→ Next: this whole shape — model, cost, gradient, optimizer — didn't stop existing once the towers were solved. Where does it show up next?

14Stage 14 — The bigger picture

The same four-part skeleton, wearing different clothes

What I knew before this stage: how to fit a line, and then a hyperplane, to towers. What I didn't know: that I'd just built the actual skeleton underneath most of modern machine learning.

Naming the skeleton explicitly

Strip away everything specific to towers and every stage of this journey reduces to the same four-part shape, repeated with different specifics each time:

  1. A model — a hypothesis with adjustable parameters, mapping inputs to a prediction. Mine was ŷ = w·x + b.
  2. A cost — a single number, computed from the parameters and the data, that gets smaller as predictions get better. Mine was Σ(ŷᵢ − yᵢ)².
  3. A gradient — the cost's sensitivity to each parameter, telling me which way is downhill.
  4. An optimizer — a rule for using the gradient (and sometimes curvature) to actually update the parameters, repeatedly, until the cost stops improving.

Nothing about this shape is specific to lines, towers, or even regression. It's the shape of "learning parameters from data" in general.

Swap the model → logistic regression

Wrap the same linear combination w·x + b in a sigmoid function to squash it into (0,1), and the model becomes a probability instead of a raw number — the beginning of classification instead of regression. The cost usually changes too (cross-entropy instead of squared error, because squared error stops being the "right" measure of badness once predictions are probabilities), but the gradient is still found by differentiating that cost with respect to the parameters, and the optimizer is still gradient descent (or Newton, or a variant) updating parameters by a scaled gradient. Nothing structurally new — a different model, a matching cost, the same machinery.

Swap the model → a neural network

Stack several of these linear-combination-plus-nonlinearity units in layers, feeding one layer's output into the next layer's input, and the "model" becomes a neural network. The cost is still a single number measuring prediction badness. The gradient is still needed for every parameter — except now there are potentially millions of them, spread across many layers, and computing ∂C/∂(each weight) by hand the way Stage 4 did for m and b would be hopeless. Backpropagation is the name for doing exactly that differentiation efficiently, by repeatedly applying the chain rule backward through the layers — which is the same chain rule Stage 4 used to differentiate a sum of squares, just applied through a much deeper composition of functions instead of one.

Swap the optimizer → momentum and Adam

Stage 5 and Stage 13 both surfaced the same weakness in plain gradient descent: a single global learning rate is either unstable in high-curvature directions or too slow in low-curvature ones. Rather than fixing this by rescaling features, a whole family of optimizers fixes it by making the update rule itself smarter — momentum accumulates a running average of past gradients so the walk builds speed through consistently-downhill directions and damps oscillation in noisy ones; Adam additionally tracks a per-parameter estimate of gradient magnitude and effectively gives each parameter its own adaptive learning rate — a softer, cheaper cousin of what Newton's method does exactly with the full Hessian. Both are still, at their core, "use the gradient to decide how to update the parameters" — just with a more sophisticated rule than "subtract α times the gradient."

Add a term to the cost → regularization

Nothing stops the cost function from being more than just squared error. Adding a penalty term like λΣwⱼ² (ridge regression) or λΣ|wⱼ| (lasso) discourages weights from growing arbitrarily large, which helps prevent a model from fitting noise in the training data too closely. The mechanical impact on everything built in this journey is small and direct: the new cost still needs a gradient, and since differentiation distributes over addition, the penalty term just contributes an extra 2λwⱼ (or similar) added onto the gradient Stage 4 already derived — the optimizer loop itself doesn't change at all.

Swap "all the data every step" → stochastic / mini-batch gradient descent

Every gradient in this journey was computed using the entire dataset at once — Stage 10's vectorized data @ error sums over every sample simultaneously. That's fine for 5 or even 1000 towers, but for datasets with millions of samples, computing one exact gradient before taking a single step becomes itself the bottleneck. Stochastic and mini-batch gradient descent compute an approximate gradient from a small random subset of the data each step instead — noisier, but far cheaper per step, and correct on average. This is a direct extension of exactly the vectorized sum from Stage 10, just applied to a subset of rows instead of all of them.

What this journey actually was

None of these are new ideas layered on top of what came before — they're the same four-part skeleton (model, cost, gradient, optimizer) with one part swapped at a time. Learning that skeleton on the smallest possible example — five towers and a straight line — is what makes it recognizable everywhere else it shows up.

What I actually learned — Stage 14
Concepts I can now explain
  • The model / cost / gradient / optimizer skeleton as the reusable shape underneath very different-looking ML methods.
  • Backpropagation as the chain rule from Stage 4, applied through many layers instead of one expression.
  • Regularization, momentum/Adam, and mini-batching as targeted swaps of one part of the skeleton, not new machinery.
What confused me

Before this journey, "logistic regression," "neural network," and "gradient descent optimizer" felt like three separate, unrelated topics to memorize independently.

What resolved it

Building the skeleton once, from scratch, on towers — with my own bugs and my own fixes — made it possible to see it again immediately underneath each of these, instead of having to relearn a new-looking pipeline from zero every time.

Can I derive it myself?

For ridge regression's penalized cost C(θ) = Σ(ŷᵢ−yᵢ)² + λΣⱼwⱼ², derive ∂C/∂wⱼ by differentiating each term separately, and confirm it's exactly the ordinary least-squares gradient from Stage 10 plus one new, simple term.

Active recall
What are the four parts of the "skeleton" this whole journey built?
A model (parameterized hypothesis mapping inputs to predictions), a cost (a single number measuring how bad the predictions are), a gradient (the cost's sensitivity to each parameter), and an optimizer (a rule for using the gradient to update the parameters until the cost stops improving).
In what sense is backpropagation "the same" as the derivative work in Stage 4?
Both use the chain rule to compute how the cost changes with respect to each parameter. Stage 4 applied it to one composed expression (a squared linear residual); backpropagation applies the same rule repeatedly, layer by layer, through a much deeper composition of functions — the underlying calculus operation is identical.
Why doesn't adding a regularization term require rebuilding the optimizer?
Because differentiation distributes over addition: the gradient of (original cost + penalty term) is just (original gradient) + (penalty's gradient). The optimizer still only needs "a gradient" to do its update — it doesn't care which cost function that gradient came from.

→ Last: if I strip away every notebook, every bug, and every stage number, what's the one mental model I actually want to keep?

15Stage 15 — Mental model

The whole journey, compressed back down to one sentence per stage

The central question from the top of this page, answered end to end.

"I had towers with coordinates and no idea how to relate them — how did that turn into a general parameter-optimization system I built myself?" Because every stage below forced the next one to exist. Nothing here was reached for in advance; each concept became necessary only once the previous one ran out of room.

The chain, compressed

  1. A vague real-world problem only becomes solvable once it's translated into coordinates — and even then, my own problem statement and my own code quietly solved two different (both valid) problems.
  2. The simplest relationship between coordinates worth proposing is a line, y = mx + b — two parameters, easy to reason about, almost never a perfect fit.
  3. "Almost never a perfect fit" demands a number for badness — residual, then squared error, then total cost — precise enough to compare any two candidate lines.
  4. A cost that depends on two parameters is really a surface, not a curve — and "best line" means the lowest point of that surface, which only has one answer once there's enough data to fully determine both parameters.
  5. Finding the bottom of a surface without checking every point by hand requires differentiation — the gradient, pointing uphill, and its negative, pointing down.
  6. Walking downhill repeatedly is gradient descent — and getting the step size wrong doesn't just slow it down, it can make it oscillate forever or blow up to infinity, depending on exactly how wrong.
  7. Watching numbers scroll by isn't understanding — recording every step's parameters (carefully, respecting the difference between rebinding and mutating) turns a sequence of numbers into a video of a line visibly walking toward the data.
  8. Differentiating symbolically every single iteration is correct but wasteful — compiling the derivative once (lambdify), and eventually writing the known closed-form gradient directly, removes that waste entirely.
  9. Slope alone tells you which way, but curvature (the Hessian) tells you how far — and for a cost this well-behaved (exactly quadratic), curvature-aware Newton's method reaches the exact answer in a single step.
  10. One input number stops being enough the moment towers (or anything) are described by more than one measurement — the model grows from y = mx + b to ŷ = w₁x₁ + ... + wₙxₙ + b, with no new ideas, just more parameters.
  11. More parameters means more loops — until every one of those loops is recognized as a matrix operation in disguise: broadcasting, element-wise products, axis-sums, and dot products, all faster and all exactly equivalent to the loop they replace.
  12. A class that "works for 2 features" isn't the same as a class that's actually general in the number of features — that only happens once every shape in the computation is derived from data, not hard-coded.
  13. Code that runs isn't code that's correct — the sharpest test available is synthetic data with a known answer, checked two ways: does the optimizer converge to it, and does cost/gradient evaluate to exactly zero at the true parameters?
  14. A working, verified optimizer can still be painfully slow — because one learning rate has to serve every parameter, and parameters with very different curvature (often from features on very different scales) can't all be served well by the same number.
  15. None of this was really about towers — model, cost, gradient, optimizer is the same skeleton underneath logistic regression, neural networks, regularization, and modern optimizers; only the specific pieces change.

If I forget every notebook, this is what I want to still know

The one-paragraph version

Turn a problem into numbers. Propose a simple relationship between them, with parameters you don't yet know. Define a single number that measures how wrong those parameters currently are. Use calculus to find which way to nudge each parameter to make that number smaller — and, if you can afford it, how far to nudge it. Repeat until nudging stops helping. Check your work against a case where you already know the right answer. Then watch that exact same shape reappear, with different labels, almost everywhere else in machine learning.

Back to the beginning: the hero widget at the top of this page is still just five towers and one line. Everything between here and there is the answer to "why did that ever need to become more complicated than dragging two sliders?"