Tensor Flow 📂 Data Pipelines and Good Training · 4 of 6 37 min read

Regularisation by Hand: L2, Dropout and Batch Norm in TensorFlow

Fight overfitting with three tools written by hand: an L2 weight penalty, tf.nn.dropout and a BatchNorm layer with moving averages. Compare validation loss with and without each one, and check your BatchNorm against Keras. Drag lambda on a live curve fit, see neurons drop out, and step through batch norm number by number.

Section 01

The Story — Training With a Handicap

Rules That Stop You From Cheating
A football coach worries that his team relies too much on one star player. So in training he makes rules. Rule 1: no single player may touch the ball too often (keep weights small — L2). Rule 2: before each drill, a few random players sit out (dropout). Everyone must learn to play every role.

He also makes sure every player warms up to the same level before the match, so nobody starts too cold or too hot (batch normalisation).

On match day the rules are lifted, and the team is stronger and more reliable. That is regularisation: making training a little harder so the model works better on new data.

In the last lesson a big network memorised 20 noisy points and got worse on new data. Early stopping was one cure. This lesson gives you three more, all written by hand: L2 weight decay, dropout and batch normalisation.


Section 02

The Test Bench

Same problem as before: 20 noisy sine points to learn from, 100 for validation, and a network that is far too big.

import tensorflow as tf
import numpy as np, math

rng = np.random.default_rng(1)
def make(n):
    x = rng.uniform(-3, 3, (n, 1)).astype("float32")
    return x, (np.sin(x) + rng.normal(0, 0.3, x.shape)).astype("float32")
x_train, y_train = make(20)
x_val, y_val = make(100)

class Dense(tf.Module):
    def __init__(self, n_in, n_out, act=None):
        super().__init__()
        self.w = tf.Variable(tf.random.normal([n_in, n_out], stddev=math.sqrt(2 / n_in)))
        self.b = tf.Variable(tf.zeros([n_out]))
        self.act = act
    def __call__(self, x):
        z = x @ self.w + self.b
        return self.act(z) if self.act else z

class Net(tf.Module):
    def __init__(self, drop=0.0):
        super().__init__()
        tf.random.set_seed(0)
        self.l1, self.l2, self.l3 = Dense(1, 256, tf.nn.relu), Dense(256, 256, tf.nn.relu), Dense(256, 1)
        self.drop = drop
    def __call__(self, x, training=False):
        h = self.l1(x)
        if training and self.drop: h = tf.nn.dropout(h, rate=self.drop)
        h = self.l2(h)
        if training and self.drop: h = tf.nn.dropout(h, rate=self.drop)
        return self.l3(h)
    def weight_norm(self):
        return float(tf.sqrt(sum(tf.reduce_sum(tf.square(l.w)) for l in (self.l1, self.l2, self.l3))))

def train(l2=0.0, drop=0.0, epochs=1500):
    net, opt = Net(drop), tf.keras.optimizers.Adam(0.003)
    @tf.function
    def step():
        with tf.GradientTape() as tape:
            loss = tf.reduce_mean((net(x_train, training=True) - y_train) ** 2)
            if l2:
                loss += l2 * tf.add_n([tf.reduce_sum(tf.square(l.w)) for l in (net.l1, net.l2, net.l3)])
        grads = tape.gradient(loss, net.trainable_variables)
        opt.apply_gradients(zip(grads, net.trainable_variables))
    for _ in range(epochs):
        step()
    mse = lambda x, y: float(tf.reduce_mean((net(x) - y) ** 2))
    return mse(x_train, y_train), mse(x_val, y_val), net.weight_norm()

print("bench ready")
OUTPUT
bench ready

Section 03

L2 Regularisation (Weight Decay)

L2 Penalty
loss + λ · Σ w²
Big weights cost extra, so the network prefers many small weights over a few huge ones.
Effect on the Update
w ← w − lr·(g + 2λw)
Every step shrinks each weight a little toward zero — hence "weight decay".

We only penalise the weights (w), not the biases. Big weights let the network draw wild, twisting curves through every noisy point; small weights keep the curve smooth.

📈 Try It — See L2 Tame an Overfitting Curve Interactive

A polynomial model fits the noisy blue points. Raise the degree to give it more freedom — it starts chasing the noise. Then raise λ and watch the curve calm down. Orange squares are validation points the model never sees. (This uses the exact solution of L2-regularised least squares, often called ridge regression.)


Section 04

Dropout with tf.nn.dropout

During training, tf.nn.dropout(x, rate) sets a random fraction rate of values to zero, and multiplies the rest by 1 / (1 − rate) so the average stays the same. At inference time, you skip it.

tf.random.set_seed(3)
x = tf.ones([10])
print("rate 0.5 :", tf.nn.dropout(x, rate=0.5).numpy())      # kept values become 2.0

big = tf.ones([100_000])
for r in (0.2, 0.5, 0.8):
    y = tf.nn.dropout(big, rate=r)
    print(f"rate {r}: kept {float(tf.reduce_mean(tf.cast(y > 0, tf.float32))):.3f}, mean stays {float(tf.reduce_mean(y)):.3f}")
OUTPUT
rate 0.5 : [0. 2. 0. 0. 2. 2. 0. 0. 2. 0.] rate 0.2: kept 0.799, mean stays 0.999 rate 0.5: kept 0.500, mean stays 1.001 rate 0.8: kept 0.198, mean stays 0.991
🎲 Try It — Dropout Visualiser Interactive

Each circle is a neuron in a hidden layer. Press Training step to drop a random set. Kept neurons are scaled up. Switch to inference: nothing is dropped and nothing is scaled.


Section 05

Batch Normalisation by Hand

Batch norm makes every feature of a layer's output have mean 0 and variance 1 within the batch, then lets the network rescale it with two learned vectors, γ (gamma) and β (beta).

01
Batch statistics
μ = mean(x, axis=0), σ² = var(x, axis=0) — one per feature.
02
Normalise
x̂ = (x − μ) / √(σ² + ε)
03
Scale and shift
y = γ · x̂ + β (γ, β are trainable).
04
Remember for inference
Keep running averages of μ and σ². At inference, use them instead of batch statistics.
class BatchNorm(tf.Module):
    def __init__(self, n, momentum=0.99, eps=1e-3):
        super().__init__()
        self.gamma = tf.Variable(tf.ones([n]))
        self.beta = tf.Variable(tf.zeros([n]))
        self.moving_mean = tf.Variable(tf.zeros([n]), trainable=False)
        self.moving_var = tf.Variable(tf.ones([n]), trainable=False)
        self.momentum, self.eps = momentum, eps

    def __call__(self, x, training=False):
        if training:
            mean, var = tf.nn.moments(x, axes=[0])
            m = self.momentum
            self.moving_mean.assign(m * self.moving_mean + (1 - m) * mean)
            self.moving_var.assign(m * self.moving_var + (1 - m) * var)
        else:
            mean, var = self.moving_mean, self.moving_var
        return self.gamma * (x - mean) / tf.sqrt(var + self.eps) + self.beta

tf.random.set_seed(5)
ours = BatchNorm(4)
keras_bn = tf.keras.layers.BatchNormalization(momentum=0.99, epsilon=1e-3)
for _ in range(5):
    batch = tf.random.normal([32, 4], mean=10.0, stddev=3.0)
    a = ours(batch, training=True)
    b = keras_bn(batch, training=True)
print("training output : mean", abs(a.numpy().mean(0)).round(3), " std", a.numpy().std(0).round(3))
print("max diff vs Keras (training) :", float(tf.reduce_max(tf.abs(a - b))))
test = tf.random.normal([8, 4], mean=10.0, stddev=3.0)
print("max diff vs Keras (inference):", float(tf.reduce_max(tf.abs(ours(test) - keras_bn(test, training=False)))))
print("trainable:", len(ours.trainable_variables), " non-trainable:", len(ours.variables) - len(ours.trainable_variables))
OUTPUT
training output : mean [0. 0. 0. 0.] std [1. 1. 1. 1.] max diff vs Keras (training) : 4.76837158203125e-07 max diff vs Keras (inference): 9.5367431640625e-07 trainable: 2 non-trainable: 2
💡
Why Batch Norm Helps

It keeps each layer's inputs at a steady scale, so you can use higher learning rates and deep nets train faster. The batch statistics also add a little noise, which acts as a mild regulariser. Remember the training flag: batch statistics while training, running averages at inference.

⚖️ Try It — Batch Norm Step by Step Interactive

Type one feature's values across a batch. See the mean and variance, the normalised values, and the output after γ and β. Try values like 100, 102, 98, 101 — very different from the default.


Section 06

The Results

Now we train the over-sized network three ways on the same data and compare.

for name, kw in [("no regularisation", {}), ("L2, lambda=0.01  ", {"l2": 1e-2}), ("dropout 0.2      ", {"drop": 0.2})]:
    tr, va, norm = train(**kw)
    print(f"{name}: train {tr:.4f}  val {va:.4f}  weight norm {norm:6.2f}")
OUTPUT
no regularisation: train 0.0539 val 0.2818 weight norm 44.69 L2, lambda=0.01 : train 0.0609 val 0.1299 weight norm 5.90 dropout 0.2 : train 0.0561 val 0.1505 weight norm 39.76
🏆
Lower Validation Loss

Without regularisation the network has the lowest training loss but the worst validation loss — it memorised the noise. L2 keeps the weights much smaller and roughly halves the validation loss. Dropout also helps. The training loss goes up a little: that is the price, and it is worth paying.

MethodKnobTypical valuesWhere
L2 / weight decayλ1e-5 … 1e-2Added to the loss (or use AdamW)
Dropoutrate0.1 – 0.5After dense layers; training only
Batch normmomentum, ε0.99, 1e-3After a Dense/Conv, before the activation
Early stoppingpatience5 – 20The training loop
More data / augmentation——The input pipeline — often the best of all

Section 07

Golden Rules

🛡️ Regularisation — Rules to Remember
1
Regularise when validation loss is much worse than training loss.
2
L2: add λ · Σ w² over the weights (not biases) to the loss.
3
Dropout: tf.nn.dropout(h, rate) only when training=True. It rescales kept values by 1/(1−rate).
4
Batch norm uses batch statistics while training and moving averages at inference. Never mix them up.
5
Every model needs a training flag once it has dropout or batch norm.