Tensor Flow 📂 Variables and Automatic Gradients · 5 of 5 38 min read

Gradient Descent by Hand in TensorFlow

Build gradient descent yourself with tf.GradientTape and assign_sub. Minimise a simple loss, then train linear regression from scratch and compare it with the SGD optimizer. Roll a ball downhill to see how the learning rate works, train a line live on points you click, and fix the most common training-loop bug.

Section 01

The Story — Walking Down a Mountain in the Fog

Feel the Slope, Take a Step, Repeat
You are on a mountain in thick fog. You want to reach the valley, but you cannot see it.

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.


Section 02

The One-Line Update Rule

Gradient Descent
w ← w − lr × dL/dw
Move each weight a small step against its gradient. "Against" means downhill.
In TensorFlow
w.assign_sub(lr * grad)
Update the variable in place. Never write w = w - lr * grad.
01
Forward pass
Inside a tape, compute the prediction from the current weights.
02
Loss
Measure how wrong the prediction is. One number.
03
Gradients
tape.gradient(loss, [w, b]) — which way is uphill for each weight.
04
Update
w.assign_sub(lr * dw) — step downhill.
05
Repeat
Go back to step 1. Each loop is one training step.

Section 03

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}")
OUTPUT
step 0: loss= 25.000 grad=-10.000 new w=0.0000 step 1: loss= 9.000 grad= -6.000 new w=1.2000 step 2: loss= 3.240 grad= -3.600 new w=1.9200 step 3: loss= 1.166 grad= -2.160 new w=2.3520 step 4: loss= 0.420 grad= -1.296 new w=2.6112 step 5: loss= 0.151 grad= -0.778 new w=2.7667 step 6: loss= 0.054 grad= -0.467 new w=2.8600 step 7: loss= 0.020 grad= -0.280 new w=2.9160

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.


Section 04

Try It — Learning Rate Playground

⛰️ Roll the Ball Downhill Interactive

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.

loss per step
Learning rateWhat happensFix
Too smallVery slow progress. May stop early on a flat part.Increase it 3× – 10×
Just rightLoss falls quickly and smoothly.Keep it
A bit too bigZig-zags across the valley but still gets there.Lower it a little
Far too bigEach jump lands higher. Loss grows to inf or nan.Divide it by 10

Section 05

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}")
OUTPUT
epoch 0 loss=12.1057 w=0.547 b=0.099 epoch 20 loss=0.2349 w=1.995 b=0.872 epoch 40 loss=0.2211 w=1.997 b=0.966 epoch 60 loss=0.2209 w=1.997 b=0.977 epoch 80 loss=0.2209 w=1.997 b=0.978 epoch 100 loss=0.2209 w=1.997 b=0.979
🎉
You Just Trained a Model

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.

Animated Diagram — The Training Loop
1 · forward 2 · loss 3 · gradient 4 · update w, b get a little better every lap

One lap = one training step. Real models run thousands or millions of laps.


Section 06

Try It — Train a Line Live

📈 Your Data, Your Training Loop Interactive

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.

loss (MSE) per step

Section 07

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}")
OUTPUT
SGD optimizer -> w=1.997 b=0.979 loss=0.2209
⚡
Make It Fast With tf.function

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.


Section 08

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)
OUTPUT
step 0: type(w)=ResourceVariable grad=-6.0 step 1: type(w)=EagerTensor grad=None TypeError: unsupported operand type(s) for *: 'float' and 'NoneType'
🐛
What Went Wrong

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

SymptomLikely causeFix
Gradient is None on step 2w = w - ... replaced the variableUse assign_sub
Loss becomes nan or infLearning rate too bigDivide lr by 10
Loss barely movesLearning rate too small, or features on very different scalesRaise lr; normalise inputs
Loss goes up and down wildlylr a bit too big, or batches too smallLower lr a little
Gradient is None from step 1Loss computed outside the tape, or int variableMove maths inside; use floats

Section 09

Golden Rules

⛰️ Gradient Descent — Rules to Remember
1
The whole algorithm is one line: w.assign_sub(lr * grad), repeated.
2
Open a new tape each step. The forward pass and loss go inside it.
3
The learning rate is the most important setting. If the loss explodes, divide it by 10.
4
Never rebind a variable with w = w - .... Always use assign_sub or an optimizer.
5
An optimizer's apply_gradients does the same update. Keras fit() runs this loop for you.
6
Gradient descent finds a valley, not always the best one. The start point matters.
You have completed Variables and Automatic Gradients. View all sections →