Training with gradient descent
Adjust weights step by step to reduce the loss.
Create a free account Try part of it here
Why it matters
Linear regression has an exact formula for its best weights. So why train it step by step? Because almost nothing else has one. Logistic regression, neural networks and every LLM learn with the loop you'll write here. Shuffle the data. Take a small batch. Work out which way to nudge the weights. Take a step. Repeat. Once you've written that loop yourself, on a model you can check against the exact answer, every training script you read later will look familiar.
What you'll be able to do
- Train a regression model on real data with gradient descent, from scratch
- Use mini-batches, epochs and shuffling the way deep learning does
- Read a training loss curve
- Know when gradient descent is needed and when an exact solution exists
What it covers
- The training loop
- The gradient for one batch
- Worked example: one mini-batch step by hand
- From scratch on the customer data
- Reading loss curves
- When do you need gradient descent?
The training loop
Picture a teacher with a pile of 600 practice essays from one student. She could read all 600 before giving any feedback. That's slow. Instead, she reads a handful, gives a quick note, and the student adjusts. Then she reads the next handful. When she reaches the bottom of the pile, she shuffles it and starts again.
Training a model works the same way. The model's weights are the numbers it can adjust. The loss is a score for how wrong its predictions are, and lower is better. In the Phase 2 lesson on walking downhill, you met gradient descent: find the uphill direction for the loss (the gradient), then step the other way. The size of the step is set by the learning rate.
Here is the whole loop in plain steps:
initialize weights (zeros or small random numbers)
repeat for each epoch:
shuffle the training rows
for each mini-batch of B rows:
compute predictions and the gradient on that batch
weights ← weights − learning_rate × gradient
record the loss
Three words in it are new:
- An epoch is one pass over all the training data. It's one trip through the whole pile of essays.
- A mini-batch is a small random group of rows used for one update. It's the handful the teacher reads before giving a note. Each step is cheap, so training on billions of examples becomes possible.
- Shuffling mixes the rows before each epoch. Without it, the batches would come in the same order every time. If the data were sorted, say all of one customer group together, each stretch of batches would pull the weights toward that group. Shuffling stops that.
The letter B in the loop is the batch size: how many rows go into each mini-batch.
This is the training loop in the lesson, drawn: compute the errors, get the gradient, take a step.
Show the answer
100
There's one update per batch. The epoch holds 10,000 / 100 = 100 batches, so it makes 100 updates.
The gradient for one batch
Each update needs the gradient for the batch in hand. For linear regression with mean squared error, you met the formula in Phase 2. Here are its symbols, one at a time:
- is the table of inputs for the batch. It has one row per example and one column per feature.
- It also has an extra column filled with 1s. The weight on that column acts as the intercept, the starting amount.
- is the list of weights, one per column.
- is the list of true values, one per row.
- is the number of rows in the batch.
- , said "X transposed", is the same table flipped so rows become columns.
- is the loss for the batch: the mean squared error.
- , said "grad L", is the gradient: one number per weight, saying how the loss changes as that weight goes up.
In plain words: make a prediction for each row and find its error (prediction minus truth). Then, for each weight, multiply every error by that weight's feature and add them up. Finally, multiply by 2 and divide by the number of rows. A weight whose feature lines up with big errors gets a big gradient, so it moves the most.
The worked example below runs this on two rows, first by hand, then in Python.
Go deeper Where the formula comes from
The loss for a batch is the average of the squared errors. Take one row with error . Its squared error is , and the slope of is . By the chain rule, a weight changes the error at a rate equal to its own feature value. So that row's gradient for the weight is 2 × error × feature. Averaging over the rows gives the formula above. The part is how matrix math writes "for each weight, add up error times feature".
Worked example: one mini-batch step by hand
A batch has two rows with one feature each. The feature values are 1 and 2. The true values are 3 and 5. The weight starts at 0. So does the intercept, . The learning rate is 0.05.
- Predictions: with everything at 0, both predictions are 0.
- Errors: prediction minus truth gives 0 − 3 = −3 and 0 − 5 = −5.
- Gradient for the weight: error times feature, added up, gives (−3 × 1) + (−5 × 2) = −13. Multiply by 2 and divide by 2 rows: still −13.
- Gradient for the intercept: its "feature" is always 1, so add up the errors: −3 + −5 = −8. Times 2, divided by 2: still −8.
- Step: the new weight is 0 − 0.05 × (−13) = 0.65. The new intercept is 0 − 0.05 × (−8) = 0.4.
Run the code to check each line, then see NumPy's one-line version agree.
import numpy as np x = [1, 2] # the feature, one value per row in the batch y = [3, 5] # the true values w, b = 0.0, 0.0 # the weight and the intercept, starting at zero lr = 0.05 # the learning rate n = len(x) # rows in this batch errors = [(w * xi + b) - yi for xi, yi in zip(x, y)] grad_w = 2 / n * sum(e * xi for e, xi in zip(errors, x)) grad_b = 2 / n * sum(e * 1 for e in errors) print("errors:", errors) print("gradient:", grad_w, grad_b) w = w - lr * grad_w b = b - lr * grad_b print("new weight and intercept:", w, b) X = np.array([[1.0, 1.0], [2.0, 1.0]]) # the feature column, then the column of ones print("same gradient in NumPy:", 2 / n * X.T @ (X @ np.zeros(2) - np.array(y)))
The errors are −3 and −5, the gradient is −13 and −8, and the step lands on 0.65 and 0.4. Both gradients are negative, which means the loss goes down as these numbers go up. So the step raises both. The last line builds the table with its column of 1s and gets the same −13 and −8 from the formula. The loop on the customer data below uses that same NumPy line on batches of 32 rows.
w = 3.0 grad = -4.0 lr = 0.25 w -= lr * grad print(w)
Show the answer
4.0
The update subtracts learning rate times gradient: 3.0 − 0.25 × (−4.0) = 3.0 + 1.0 = 4.0. A negative gradient means downhill is toward bigger values, so the weight goes up.
From scratch on the customer data
Now the full loop on a real task. The shop has 600 customers. The goal is to predict each one's lifetime value (their total spend so far) from four features. Those are orders last year, average order value, months as a customer, and loyalty-club membership.
Before the loop, the code does two set-up steps:
- It standardizes each feature, as in the data preparation lesson. Each one is rescaled so its average is 0 and its spread is 1.
- It adds the column of 1s for the intercept.
Then it trains for 30 epochs with batches of 32 and a learning rate of 0.05. The 600 rows make 18 full batches plus one of 24, so 19 updates per epoch and 570 in all. A fixed seed makes the shuffling the same on every run. At the end, it prints the weights next to the exact answer from np.linalg.lstsq ("least squares").
import numpy as np import pandas as pd import requests import matplotlib.pyplot as plt df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) features = ["orders_last_year", "avg_order_value", "tenure_months", "trail_club"] X_raw = df[features].to_numpy(dtype=float) mean, std = X_raw.mean(axis=0), X_raw.std(axis=0) X = np.column_stack([(X_raw - mean) / std, np.ones(len(df))]) # standardized features + bias column y = df["lifetime_value"].to_numpy() rng = np.random.default_rng(0) w = np.zeros(X.shape[1]) lr, batch, losses = 0.05, 32, [] for epoch in range(30): order = rng.permutation(len(y)) for start in range(0, len(y), batch): idx = order[start:start + batch] grad = 2 / len(idx) * X[idx].T @ (X[idx] @ w - y[idx]) w -= lr * grad losses.append(np.mean((X @ w - y) ** 2)) exact, *_ = np.linalg.lstsq(X, y, rcond=None) print("gradient descent:", np.round(w, 1).tolist()) print("exact solution: ", np.round(exact, 1).tolist()) plt.plot(losses) plt.yscale("log") plt.xlabel("epoch") plt.ylabel("mean squared error")
After 30 epochs, every weight is within about $10 of the exact answer. It doesn't land exactly on it. Each batch gives a slightly different gradient, so the weights keep jiggling around the best spot. A learning rate that shrinks late in training closes that gap. That's the next lesson.
The chart is the loss curve, one point per epoch. The loss drops in the first epoch or two, then flattens into a slightly jagged line. That's the classic shape.
Go deeper Why standardize first?
One learning rate is shared by every weight. In this data, orders last year run from 0 to 18, while average order value runs from about $18 to $194. Left unscaled, the same step size would be too big for one weight and too small for the other. The loss surface becomes a long, narrow valley, and gradient descent zig-zags down it slowly. Putting every feature on the same scale lets one learning rate suit them all. Forgetting this step often makes training crawl or blow up.
Go deeper Why the mini-batch steps are noisy
The gradient from 32 rows is an estimate of the gradient from all 600. It's like tasting a spoonful to judge the whole pot of soup. Most spoonfuls taste about right, but each one is a little different. So each step points roughly downhill, never perfectly. On average the steps still lead to the bottom, and each one costs a fraction of a full pass.
Reading loss curves
The loss curve is the first thing to check after any training run. Here's what its common shapes usually mean:
| Shape | Usual meaning |
|---|---|
| drops then flattens | converged; more epochs won't help |
| still falling at the end | train longer (or raise the learning rate) |
| jagged but trending down | normal with small batches; the noise is the batch sampling |
| rising or exploding | learning rate too high, or a bug |
| training loss falls, validation loss rises | overfitting (a later lesson) |
"Converged" means the weights have settled near the best spot. "Validation" data is data held back from training, as in the lesson on train, validation and test sets.
Always plot the loss on held-out data too. A model is only getting better if that curve falls. A falling training loss can also mean the model is memorizing its training rows.
Show the answer
The learning rate is too high, or there's a bug
Small-batch noise makes the curve jagged, but it still trends down. A loss that keeps rising or explodes usually means the steps overshoot the bottom, or the code has a bug.
When do you need gradient descent?
| Model | Exact solution? | How it's trained |
|---|---|---|
| Linear regression | yes (least squares) | exact for small data; gradient descent for huge data |
| Logistic regression | no | iterative optimization (gradient-based) |
| Decision trees, forests | no gradients needed | greedy splitting (later lessons) |
| Neural networks, LLMs | no | mini-batch gradient descent (usually Adam) |
For linear regression on 600 customers, you'd use the exact solution in practice. What you keep from this lesson is the loop itself. A PyTorch training script, which you'll write in Phase 4, has the same shape. PyTorch is a popular deep-learning library. Look for the familiar parts: epochs, shuffled batches, a loss, the gradient and the update.
for epoch in range(epochs): for xb, yb in loader: # shuffled mini-batches loss = ((model(xb) - yb) ** 2).mean() optimizer.zero_grad() loss.backward() # the gradient, by backpropagation optimizer.step() # w ← w − lr × gradient (or Adam's version)
The loader hands out shuffled mini-batches. The line loss.backward() works out the gradient for you, using a method called backpropagation. The line optimizer.step() applies the update. Adam, mentioned in the table, is a smarter version of that update and the usual choice for neural networks.
Show the answer
Use the exact least-squares solution
Linear regression has an exact solution, and 600 rows is tiny. Gradient descent is for models with no exact formula, or data too large to solve in one go.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
What is an epoch?