The Story — Walking Down a Mountain in the Fog
So you feel the ground with your feet. It slopes down to the left. You take a step to the left. You feel again. You step again. Step by step, you reach the bottom.
This is gradient descent. The height is the loss. The slope under your feet is the gradient. How big each step is — that is the learning rate. Too small and you take all day. Too big and you jump right over the valley.
You now know how to get gradients with tf.GradientTape. In this lesson you will use them to train. No Keras fit(), no magic. Just a loop you write yourself. Every deep learning library does exactly this inside.
The One-Line Update Rule
w = w - lr * grad.Warm-up: Find the Bottom of (w − 3)²
The lowest point is at w = 3. Let us start far away, at w = −2, and let gradient descent find it.
import tensorflow as tf
w = tf.Variable(-2.0)
lr = 0.2
for step in range(8):
with tf.GradientTape() as tape:
loss = (w - 3.0) ** 2
grad = tape.gradient(loss, w)
w.assign_sub(lr * grad)
print(f"step {step}: loss={loss.numpy():7.3f} grad={grad.numpy():+7.3f} new w={w.numpy():.4f}")
Each step closes 40% of the remaining gap (because 1 − 2 × lr = 0.6). The steps get smaller as the slope gets flatter. That is natural: near the bottom, the gradient is small.
Try It — Learning Rate Playground
Pick a loss curve, a start point and a learning rate. Press Play to watch gradient descent step by step. Try lr = 0.05 (slow), 0.4 (good), 0.95 (zig-zag) and 1.05 (explodes). On the two-valley curve, change the start and see which valley you reach.
| Learning rate | What happens | Fix |
|---|---|---|
| Too small | Very slow progress. May stop early on a flat part. | Increase it 3× – 10× |
| Just right | Loss falls quickly and smoothly. | Keep it |
| A bit too big | Zig-zags across the valley but still gets there. | Lower it a little |
| Far too big | Each jump lands higher. Loss grows to inf or nan. | Divide it by 10 |
Real Training: Fit a Line From Scratch
Now a real model. We make data from the line y = 2x + 1 plus some noise. The model does not know 2 or 1. It must learn them.
tf.random.set_seed(42)
X = tf.random.uniform([100], -3, 3)
y = 2.0 * X + 1.0 + tf.random.normal([100], stddev=0.5) # true w=2, b=1
w = tf.Variable(0.0)
b = tf.Variable(0.0)
lr = 0.05
for epoch in range(101):
with tf.GradientTape() as tape:
pred = w * X + b
loss = tf.reduce_mean((pred - y) ** 2) # mean squared error
dw, db = tape.gradient(loss, [w, b])
w.assign_sub(lr * dw)
b.assign_sub(lr * db)
if epoch % 20 == 0:
print(f"epoch {epoch:3d} loss={loss.numpy():.4f} w={w.numpy():.3f} b={b.numpy():.3f}")
The model found w ≈ 2 and b ≈ 1 by itself. The loss stops near 0.22. That is about the size of the noise we added (0.5² = 0.25). No line can remove random noise, so this is the best possible result.
One lap = one training step. Real models run thousands or millions of laps.
Try It — Train a Line Live
Click on the chart to add points. Then press Train. The green line starts flat (w = 0, b = 0) and learns to fit your points. Change the learning rate while it trains.
The Same Loop With an Optimizer
Writing assign_sub for every variable gets boring. An optimizer does the update for you. SGD is exactly our hand-written rule. Later you will swap in Adam with one line.
w = tf.Variable(0.0)
b = tf.Variable(0.0)
opt = tf.keras.optimizers.SGD(learning_rate=0.05)
for epoch in range(101):
with tf.GradientTape() as tape:
loss = tf.reduce_mean((w * X + b - y) ** 2)
grads = tape.gradient(loss, [w, b])
opt.apply_gradients(zip(grads, [w, b])) # w -= lr * dw ; b -= lr * db
print(f"SGD optimizer -> w={w.numpy():.3f} b={b.numpy():.3f} loss={loss.numpy():.4f}")
Put the body of the loop (tape, gradient, update) into a function and add @tf.function. TensorFlow turns it into a graph. For real models this is often several times faster. Keras fit() does this for you.
The Most Common Bug
This loop looks right. But on the second step it crashes. Can you see why?
w = tf.Variable(0.0)
for step in range(2):
with tf.GradientTape() as tape:
loss = (w - 3.0) ** 2
grad = tape.gradient(loss, w)
print(f"step {step}: type(w)={type(w).__name__:16s} grad={grad}")
try:
w = w - 0.1 * grad # BUG: makes w a plain Tensor
except TypeError as e:
print("TypeError:", e)
w = w - 0.1 * grad builds a new tensor and gives it the name w. The variable is gone. The next tape does not watch a plain tensor, so the gradient is None, and 0.1 * None fails. The fix is one word: w.assign_sub(0.1 * grad).
| Symptom | Likely cause | Fix |
|---|---|---|
| Gradient is None on step 2 | w = w - ... replaced the variable | Use assign_sub |
| Loss becomes nan or inf | Learning rate too big | Divide lr by 10 |
| Loss barely moves | Learning rate too small, or features on very different scales | Raise lr; normalise inputs |
| Loss goes up and down wildly | lr a bit too big, or batches too small | Lower lr a little |
| Gradient is None from step 1 | Loss computed outside the tape, or int variable | Move maths inside; use floats |
Golden Rules
w.assign_sub(lr * grad), repeated.w = w - .... Always use assign_sub or an optimizer.apply_gradients does the same update. Keras fit() runs this loop for you.