TensorFlow Custom Training Loops
Custom training loops give you complete control over every aspect of model training. While model.fit() handles the training loop automatically, some situations demand you write the loop yourself: training GANs with two competing models, implementing research papers with non-standard update rules, applying different learning rates to different parts of a model, or collecting custom statistics during training. Custom loops are also the gateway to understanding what TensorFlow does internally on every training step.
When to Use a Custom Training Loop
Use model.fit(): Use Custom Loop:
───────────────────────────────────────────────────────
Standard classification Training GANs
Standard regression Multiple interleaved models
Transfer learning Custom gradient manipulation
Most real-world projects Implementing research papers
Non-standard loss functions
Step-by-step debugging
The Four Core Components
A custom training loop requires four components working together:
- GradientTape — records operations so TensorFlow can compute gradients automatically
- Loss function — measures how wrong the model's prediction is
- Optimizer — uses gradients to update weights
- Metrics — track progress without affecting training
Understanding tf.GradientTape
GradientTape is the key tool for custom training. It "records" forward-pass operations on a "tape" (like a cassette tape recording audio). After the forward pass completes, you "play back" the tape in reverse to calculate how much each weight contributed to the loss. This playback process is automatic differentiation — the mathematical engine behind backpropagation.
import tensorflow as tf
x = tf.constant(3.0)
with tf.GradientTape() as tape:
tape.watch(x) # Track this variable
y = x ** 2 + 2 * x # y = x² + 2x
dy_dx = tape.gradient(y, x)
print(dy_dx) # dy/dx at x=3: 2(3) + 2 = 8.0
For model weights (which are tf.Variable objects), GradientTape tracks them automatically without needing tape.watch().
A Simple Custom Training Step
import tensorflow as tf
model = tf.keras.Sequential([
tf.keras.layers.Dense(64, activation='relu', input_shape=(20,)),
tf.keras.layers.Dense(1, activation='sigmoid')
])
optimizer = tf.keras.optimizers.Adam(learning_rate=0.001)
loss_fn = tf.keras.losses.BinaryCrossentropy()
@tf.function # Compiles this function to a TensorFlow graph for speed
def train_step(x_batch, y_batch):
with tf.GradientTape() as tape:
# Forward pass
predictions = model(x_batch, training=True)
loss = loss_fn(y_batch, predictions)
# Compute gradients
gradients = tape.gradient(loss, model.trainable_variables)
# Apply gradients to update weights
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
return loss
The @tf.function decorator compiles the function into an optimized TensorFlow computation graph. The first call takes slightly longer as TensorFlow traces the function. Every subsequent call runs much faster than pure Python because TensorFlow executes the compiled graph directly.
The Complete Custom Training Loop
import tensorflow as tf
import numpy as np
# Data
x_train = np.random.random((1000, 20)).astype('float32')
y_train = np.random.randint(0, 2, size=(1000, 1)).astype('float32')
x_val = np.random.random((200, 20)).astype('float32')
y_val = np.random.randint(0, 2, size=(200, 1)).astype('float32')
# Model
model = tf.keras.Sequential([
tf.keras.layers.Dense(64, activation='relu', input_shape=(20,)),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dense(1, activation='sigmoid')
])
optimizer = tf.keras.optimizers.Adam(0.001)
loss_fn = tf.keras.losses.BinaryCrossentropy()
# Metrics
train_loss_metric = tf.keras.metrics.Mean(name='train_loss')
train_acc_metric = tf.keras.metrics.BinaryAccuracy(name='train_acc')
val_loss_metric = tf.keras.metrics.Mean(name='val_loss')
val_acc_metric = tf.keras.metrics.BinaryAccuracy(name='val_acc')
# Datasets
train_ds = tf.data.Dataset.from_tensor_slices((x_train, y_train)).shuffle(1000).batch(32)
val_ds = tf.data.Dataset.from_tensor_slices((x_val, y_val)).batch(32)
@tf.function
def train_step(x, y):
with tf.GradientTape() as tape:
preds = model(x, training=True)
loss = loss_fn(y, preds)
grads = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(grads, model.trainable_variables))
train_loss_metric.update_state(loss)
train_acc_metric.update_state(y, preds)
@tf.function
def val_step(x, y):
preds = model(x, training=False)
loss = loss_fn(y, preds)
val_loss_metric.update_state(loss)
val_acc_metric.update_state(y, preds)
# Main training loop
for epoch in range(30):
# Reset metrics at the start of each epoch
train_loss_metric.reset_state()
train_acc_metric.reset_state()
val_loss_metric.reset_state()
val_acc_metric.reset_state()
# Training
for x_batch, y_batch in train_ds:
train_step(x_batch, y_batch)
# Validation
for x_batch, y_batch in val_ds:
val_step(x_batch, y_batch)
print(
f"Epoch {epoch+1:02d} | "
f"loss={train_loss_metric.result():.4f} | "
f"acc={train_acc_metric.result():.4f} | "
f"val_loss={val_loss_metric.result():.4f} | "
f"val_acc={val_acc_metric.result():.4f}"
)
Why training=True and training=False Matter
Some layers behave differently during training and inference:
- Dropout — randomly zeroes out neurons during training (for regularization). During inference, all neurons are active.
- BatchNormalization — uses batch statistics during training. During inference, it uses the running statistics computed across all training batches.
Passing training=True activates these training-specific behaviors. Passing training=False switches them to inference mode. Forgetting this distinction causes subtle bugs where your model performs worse during evaluation than it should.
Gradient Clipping: Preventing Exploding Gradients
In deep networks or RNNs, gradients can become very large during backpropagation, causing the model's weights to receive enormous updates that destabilize training. Gradient clipping caps the magnitude of gradients before applying them:
@tf.function
def train_step_with_clipping(x, y):
with tf.GradientTape() as tape:
preds = model(x, training=True)
loss = loss_fn(y, preds)
gradients = tape.gradient(loss, model.trainable_variables)
# Clip gradients so their total norm never exceeds 1.0
gradients, _ = tf.clip_by_global_norm(gradients, clip_norm=1.0)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
return loss
Accumulating Gradients for Large Effective Batch Sizes
Sometimes the batch size you need for stable training does not fit in GPU memory. Gradient accumulation simulates a large batch by summing gradients over several small batches before applying the update:
accumulation_steps = 8 # Simulate batch_size × 8
accumulated_gradients = [tf.Variable(tf.zeros_like(v))
for v in model.trainable_variables]
for step, (x_batch, y_batch) in enumerate(train_ds):
with tf.GradientTape() as tape:
preds = model(x_batch, training=True)
loss = loss_fn(y_batch, preds) / accumulation_steps
grads = tape.gradient(loss, model.trainable_variables)
for acc_grad, grad in zip(accumulated_gradients, grads):
acc_grad.assign_add(grad)
if (step + 1) % accumulation_steps == 0:
optimizer.apply_gradients(
zip(accumulated_gradients, model.trainable_variables)
)
# Reset accumulated gradients
for acc_grad in accumulated_gradients:
acc_grad.assign(tf.zeros_like(acc_grad))
Saving Checkpoints Inside the Loop
checkpoint = tf.train.Checkpoint(model=model, optimizer=optimizer)
checkpoint_manager = tf.train.CheckpointManager(
checkpoint, directory='./checkpoints', max_to_keep=3
)
for epoch in range(30):
# ... training steps ...
# Save checkpoint every 5 epochs
if (epoch + 1) % 5 == 0:
save_path = checkpoint_manager.save()
print(f"Checkpoint saved: {save_path}")
Custom Loop Diagram
For each epoch: │ ├── For each batch in training data: │ │ │ ├── [GradientTape context starts recording] │ │ │ ├── Forward Pass: x → model → predictions │ │ │ ├── Compute Loss: loss_fn(y_true, predictions) │ │ │ ├── [GradientTape stops recording] │ │ │ ├── Backprop: tape.gradient(loss, trainable_vars) │ │ │ ├── (Optional) Clip gradients │ │ │ └── optimizer.apply_gradients(grads, vars) │ ├── For each batch in validation data: │ └── model(x, training=False) → compute val metrics │ └── Print epoch summary → repeat
Custom training loops trade convenience for control. Once you understand this pattern, you can implement any training procedure described in a research paper. The next topic covers Callbacks — objects that plug into both model.fit() and custom loops to automate actions like saving the best model, adjusting the learning rate, or stopping training early when the model stops improving.
