Tensor Flow 📂 Data Pipelines and Good Training · 3 of 6 32 min read

Train/Validation Split and Early Stopping by Hand in TensorFlow

Split data into training and validation sets the right way, and avoid the shuffle leak that mixes them every epoch. Watch a model overfit, then write your own EarlyStopping class with patience, min_delta and restore-best-weights. Check any split in the leak checker and watch early stopping decide live on a noisy run.

Section 01

The Story — Practice Papers and the Real Exam

Memorising the Answers Is Not Learning
A student practises the same 20 questions every night. After a week she gets 100% on them. Is she ready for the exam? Not necessarily — she may have memorised the answers without understanding the subject.

A smart teacher keeps a few questions hidden. The student never practises on them. Every few days the teacher checks the hidden questions. While that score improves, the student is learning. When it starts to drop — even as practice scores keep rising — she is just memorising. Time to stop.

The hidden questions are the validation set. Stopping at the right moment is early stopping.

Section 02

Splitting a Dataset — and a Hidden Trap

The natural idea is: shuffle, then take some for training and skip them for validation. But look what happens over two epochs.

import tensorflow as tf

ds = tf.data.Dataset.range(10).shuffle(10, seed=42)          # default: reshuffle every epoch!
train, val = ds.take(8), ds.skip(8)

for epoch in range(2):
    t = sorted(int(v) for v in train)
    v = sorted(int(v) for v in val)
    print(f"epoch {epoch}: train {t}  val {v}")

seen_in_train = set()
for _ in range(2): seen_in_train |= {int(v) for v in train}
print("val items also used for training:", sorted(seen_in_train & {int(v) for v in val}))
OUTPUT
epoch 0: train [1, 2, 4, 5, 6, 7, 8, 9] val [2, 9] epoch 1: train [0, 1, 2, 3, 4, 6, 8, 9] val [7, 8] val items also used for training: [5, 9]
🚨
Data Leakage

shuffle reshuffles on every pass, and take and skip each start a new pass. So the "validation" items change every epoch and end up in training too. Your validation score becomes a lie — it looks great but means nothing.

The Fix: Split Once, Then Shuffle Only the Training Part

full = tf.data.Dataset.range(10).shuffle(10, seed=42, reshuffle_each_iteration=False)   # one fixed order
train = full.take(8).shuffle(8)          # training may reshuffle freely
val = full.skip(8)                        # validation never changes

for epoch in range(2):
    print(f"epoch {epoch}: train {sorted(int(v) for v in train)}  val {sorted(int(v) for v in val)}")
OUTPUT
epoch 0: train [0, 2, 3, 4, 6, 7, 8, 9] val [1, 5] epoch 1: train [0, 2, 3, 4, 6, 7, 8, 9] val [1, 5]

Even simpler: split your arrays (or file lists) before building the datasets, and never mix them again.

✄️ Try It — Split Checker Interactive

Choose the dataset size, the validation share and how you shuffle. See the split in 3 epochs. Red squares are validation items that leaked into training.


Section 03

Watching Overfitting Happen

We give a big network only 20 noisy points of a sine wave, and keep 100 other points for validation. Then we train for a long time.

import numpy as np
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)

def new_model():
    tf.random.set_seed(0)
    return tf.keras.Sequential([
        tf.keras.Input(shape=(1,)),
        tf.keras.layers.Dense(256, activation="relu"),
        tf.keras.layers.Dense(256, activation="relu"),
        tf.keras.layers.Dense(1)])

model = new_model()
opt = tf.keras.optimizers.Adam(0.003)

@tf.function
def train_step():
    with tf.GradientTape() as tape:
        loss = tf.reduce_mean((model(x_train) - y_train) ** 2)
    grads = tape.gradient(loss, model.trainable_variables)
    opt.apply_gradients(zip(grads, model.trainable_variables))
    return loss

def val_loss():
    return float(tf.reduce_mean((model(x_val) - y_val) ** 2))

for epoch in range(1, 401):
    loss = float(train_step())
    if epoch in (10, 40, 80, 200, 400):
        print(f"epoch {epoch:3d}  train loss {loss:.4f}  val loss {val_loss():.4f}")
OUTPUT
epoch 10 train loss 0.3210 val loss 0.2429 epoch 40 train loss 0.1472 val loss 0.1280 epoch 80 train loss 0.0590 val loss 0.1507 epoch 200 train loss 0.0532 val loss 0.1740 epoch 400 train loss 0.0466 val loss 0.2705

Train loss keeps falling. Validation loss falls at first, then climbs: after about epoch 40, the extra training only memorises the noise.


Section 04

Early Stopping by Hand

⏳ The Early Stopping Rules
monitor
Watch the validation loss after every epoch.
min_delta
Only count it as "better" if it improves by at least this much.
patience
Allow this many epochs with no improvement before stopping. Loss curves are bumpy, so do not stop at the first bad epoch.
restore
When you stop, put back the weights from the best epoch, not the last one.
class EarlyStopping:
    def __init__(self, variables, patience=20, min_delta=1e-4):
        self.variables, self.patience, self.min_delta = variables, patience, min_delta
        self.best, self.best_epoch, self.wait = float("inf"), 0, 0
        self.best_weights = None

    def update(self, epoch, value):
        """Returns True when training should stop."""
        if value < self.best - self.min_delta:
            self.best, self.best_epoch, self.wait = value, epoch, 0
            self.best_weights = [v.numpy().copy() for v in self.variables]   # snapshot
        else:
            self.wait += 1
        return self.wait >= self.patience

    def restore(self):
        for v, w in zip(self.variables, self.best_weights):
            v.assign(w)

model = new_model()
opt = tf.keras.optimizers.Adam(0.003)

@tf.function
def train_step():
    with tf.GradientTape() as tape:
        loss = tf.reduce_mean((model(x_train) - y_train) ** 2)
    grads = tape.gradient(loss, model.trainable_variables)
    opt.apply_gradients(zip(grads, model.trainable_variables))
    return loss

stopper = EarlyStopping(model.trainable_variables, patience=20)
for epoch in range(1, 401):
    train_step()
    v = val_loss()
    if stopper.update(epoch, v):
        print(f"stopped at epoch {epoch}; best was epoch {stopper.best_epoch} (val loss {stopper.best:.4f})")
        break

print(f"val loss before restore: {val_loss():.4f}")
stopper.restore()
print(f"val loss after restore : {val_loss():.4f}")
OUTPUT
stopped at epoch 69; best was epoch 49 (val loss 0.1184) val loss before restore: 0.1587 val loss after restore : 0.1184
✅
Saved Time and a Better Model

Training stopped long before epoch 400, and restoring the best weights gave the lowest validation loss we ever saw. Keras has the same idea built in: tf.keras.callbacks.EarlyStopping(patience=20, restore_best_weights=True).


Section 05

Try It — Early Stopping Simulator

⏳ When Would Training Stop? Interactive

These curves behave like a real training run. Change how noisy the validation curve is and when overfitting starts. Then set patience and min_delta and press Train. Watch the patience counter fill up.

train lossvalidation lossbest epochstop
SettingToo smallToo largeGood start
patienceStops on a random bump, too earlyWastes time training past the best point5–20 epochs (more if noisy)
min_deltaTiny noise counts as "improvement"Real progress is ignoredabout 0.1% of the loss
validation shareNoisy, unreliable scoreLess data left to learn from10–20% of the data

Section 06

Golden Rules

⏳ Validation and Early Stopping — Rules to Remember
1
Split once, before training. Never let validation data leak into training.
2
With tf.data, use reshuffle_each_iteration=False before take/skip, or split the arrays first.
3
Watch validation loss. Training loss alone always looks good.
4
Stop after patience epochs without an improvement bigger than min_delta.
5
Restore the best weights when you stop. Keep a separate test set for the final, honest score.