The Story — Why Straight Lines Are Not Enough
A Dense layer without an activation is a straight line (a linear function). Stack ten of them and — just like the rulers — you still have one linear function. The network could never learn a curved border between cats and dogs.
An activation function is a bend put after each layer. With a bend after every ruler, you can trace any shape you like.
In this lesson you will prove that stacked linear layers collapse into one, then meet the activations in tf.nn you will use every day.
Proof: Two Linear Layers = One Linear Layer
import tensorflow as tf
tf.random.set_seed(0)
W1, b1 = tf.random.normal([3, 5]), tf.random.normal([5])
W2, b2 = tf.random.normal([5, 2]), tf.random.normal([2])
x = tf.random.normal([4, 3])
two_layers = (x @ W1 + b1) @ W2 + b2
W, b = W1 @ W2, b1[tf.newaxis] @ W2 + b2 # merge them into ONE layer
one_layer = x @ W + b
print("max difference:", float(tf.reduce_max(tf.abs(two_layers - one_layer))))
with_relu = tf.nn.relu(x @ W1 + b1) @ W2 + b2
print("with relu, difference from one layer:", round(float(tf.reduce_max(tf.abs(with_relu - one_layer))), 3))
Without activation the difference is only float rounding: the two layers are one layer. With a ReLU in between, the result can no longer be squashed into a single layer.
The Activations You Need
z = tf.constant([-3.0, -1.0, 0.0, 1.0, 3.0])
print("z :", z.numpy())
print("relu :", tf.nn.relu(z).numpy())
print("leaky_relu :", tf.nn.leaky_relu(z, alpha=0.1).numpy().round(3))
print("sigmoid :", tf.nn.sigmoid(z).numpy().round(3))
print("tanh :", tf.nn.tanh(z).numpy().round(3))
print("gelu :", tf.nn.gelu(z).numpy().round(3))
Try It — Activation Explorer
Tick the activations to compare. The solid line is the function; the dashed line is its gradient (slope). Drag on the chart to move z. Type your own vector below to see what each function does to it.
| activation | f(z) | gradient f′(z) | note |
|---|
Gradients Matter: Saturation and Dead Neurons
z = tf.Variable([-6.0, -2.0, 0.0, 2.0, 6.0])
for name, fn in [("sigmoid", tf.nn.sigmoid), ("tanh", tf.nn.tanh), ("relu", tf.nn.relu)]:
with tf.GradientTape() as tape:
y = tf.reduce_sum(fn(z))
print(f"{name:8s} gradient:", tape.gradient(y, z).numpy().round(4))
| At z = ±6 the slope is almost 0 |
| Deep stacks multiply many tiny slopes |
| → "vanishing gradients", slow learning |
| Slope is exactly 1 for z > 0 — no shrinking |
| Slope is 0 for z < 0 |
| A neuron stuck below 0 is "dead" → try leaky_relu |
Softmax — From Scores to Probabilities
logits = tf.constant([2.0, 1.0, 0.1])
p = tf.nn.softmax(logits)
print("probabilities:", p.numpy().round(3), " sum =", float(tf.reduce_sum(p)))
big = tf.constant([1000.0, 999.0, 998.0])
naive = tf.exp(big) / tf.reduce_sum(tf.exp(big))
stable = tf.exp(big - tf.reduce_max(big)) / tf.reduce_sum(tf.exp(big - tf.reduce_max(big)))
print("naive softmax :", naive.numpy())
print("stable softmax:", stable.numpy().round(4))
print("tf.nn.softmax :", tf.nn.softmax(big).numpy().round(4))
For a batch of shape (batch, classes), tf.nn.softmax(logits) uses the last axis by default: one probability vector per sample. The raw scores before softmax are called logits. You will meet that word a lot in the next lesson.
Try It — Softmax Playground
Move the sliders to change each class's logit. The bars show the softmax probabilities. Change the temperature: low temperature makes the model very sure; high temperature makes it unsure. Press +1 to all and notice nothing changes.
Which Activation Where?
| Place | Task | Activation |
|---|---|---|
| Hidden layers | Any | relu (or leaky_relu, gelu) |
| Output layer | Regression (a number) | None (linear) |
| Output layer | Binary yes/no | sigmoid — or output logits and use a "from logits" loss |
| Output layer | One of many classes | softmax — or output logits and use a "from logits" loss |
| Output layer | Several labels at once | sigmoid on each output |
Golden Rules
tf.nn.relu in hidden layers by default. Pair it with He initialisation.tf.nn.softmax, never a hand-made exp / sum: the built-in version is numerically stable.