Skip to content

15. Custom Layers, Models and Training Loops

Move beyond the standard Keras Sequential and Functional APIs and learn how to build custom layers, custom models, and custom training loops with TensorFlow and Keras. Understand how forward computation, trainable parameters, automatic differentiation, gradient computation, and optimizer updates work together to provide fine-grained control over Deep Learning systems.


๐ŸŽฏ Learning Objectives

After completing this chapter, you will be able to:

  • Understand why custom Keras components are required
  • Create custom Keras layers
  • Understand the Layer base class
  • Implement the build() method
  • Implement the call() method
  • Create trainable and non-trainable variables
  • Understand trainable_weights
  • Understand non_trainable_weights
  • Build custom mathematical operations as layers
  • Create reusable custom layers
  • Create custom Keras models
  • Understand the difference between custom layers and custom models
  • Implement custom forward passes
  • Understand training=True and training=False
  • Use tf.GradientTape
  • Compute gradients manually
  • Apply gradients using an optimizer
  • Implement a custom training step
  • Build a custom training loop
  • Implement validation loops
  • Track metrics manually
  • Use tf.function
  • Understand eager execution vs graph execution
  • Understand how Keras fit() works conceptually
  • Override train_step()
  • Combine custom training logic with model.fit()
  • Implement custom losses
  • Implement custom metrics
  • Implement custom regularization
  • Understand when custom training loops are appropriate
  • Design maintainable custom Deep Learning components

๐Ÿ“– Overview

Keras provides high-level APIs that make Deep Learning development straightforward:

Sequential
Functional API
model.compile()
model.fit()
model.evaluate()
model.predict()

For many applications, these abstractions are sufficient.

However, advanced Deep Learning systems sometimes require more control.

Examples include:

Custom Layers
Custom Forward Pass
Custom Loss
Custom Optimization
Multiple Gradient Computations
Gradient Manipulation
Custom Training Logic
Adversarial Training
Contrastive Learning
Multi-Task Optimization
Research Architectures

For these situations, Keras allows developers to move down the abstraction stack.

flowchart TD

    HIGH["High-Level Keras API"]

    HIGH --> SEQ["Sequential"]
    HIGH --> FUNC["Functional API"]
    HIGH --> FIT["model.fit()"]

    CUSTOM["Custom Keras Components"]

    CUSTOM --> LAYER["Custom Layers"]
    CUSTOM --> MODEL["Custom Models"]
    CUSTOM --> TRAIN["Custom Training Loops"]

    LAYER --> LOW["Lower-Level TensorFlow Control"]
    MODEL --> LOW
    TRAIN --> LOW

    LOW --> GRAD["GradientTape"]
    LOW --> OPT["Optimizer"]
    LOW --> TENSOR["Tensor Operations"]

๐Ÿง  Why Custom Layers?

Standard Keras already provides many layers:

Dense
Conv2D
Dropout
BatchNormalization
LSTM
GRU
Embedding
MultiHeadAttention

But sometimes your architecture requires an operation that does not exist as a standard layer.

For example:

Custom Mathematical Transformation
Custom Feature Interaction
Custom Normalization
Custom Attention
Custom Routing
Custom Residual Block
Custom Research Layer

Instead of writing the operation directly inside the model, create a reusable layer.


๐Ÿงฑ Custom Layer Concept

A custom layer can be viewed as:

Input Tensor
      โ†“
Custom Layer
      โ†“
Transformation
      โ†“
Output Tensor
flowchart LR

    INPUT["Input Tensor"]

    LAYER["Custom Layer"]

    TRANSFORM["Custom Computation"]

    OUTPUT["Output Tensor"]

    INPUT --> LAYER
    LAYER --> TRANSFORM
    TRANSFORM --> OUTPUT

๐Ÿง  tf.keras.layers.Layer

Custom layers generally inherit from:

tf.keras.layers.Layer

Basic structure:

class MyLayer(tf.keras.layers.Layer):

    def __init__(self, ...):
        super().__init__()

    def build(self, input_shape):
        ...

    def call(self, inputs):
        ...

The three important concepts are:

__init__()
build()
call()

๐Ÿง  Role of __init__()

The constructor is generally used to configure the layer.

For example:

class MyLayer(tf.keras.layers.Layer):

    def __init__(
        self,
        units
    ):

        super().__init__()

        self.units = units

The constructor stores configuration.

It should not necessarily create weights that depend on the input shape.


๐Ÿง  Role of build()

build() is useful when weights depend on the input shape.

Example:

def build(
    self,
    input_shape
):

    self.w = self.add_weight(
        shape=(
            input_shape[-1],
            self.units
        ),
        initializer="random_normal",
        trainable=True
    )

    self.b = self.add_weight(
        shape=(self.units,),
        initializer="zeros",
        trainable=True
    )

This allows the layer to determine the required weight dimensions after the input shape is known.


๐Ÿง  Role of call()

call() defines the forward computation.

For example:

def call(
    self,
    inputs
):

    return tf.matmul(
        inputs,
        self.w
    ) + self.b

Conceptually:

Input
  โ†“
call()
  โ†“
Matrix Multiplication
  โ†“
Bias
  โ†“
Output

๐Ÿงช Complete Custom Dense Layer

import tensorflow as tf


class CustomDense(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        units
    ):

        super().__init__()

        self.units = units

    def build(
        self,
        input_shape
    ):

        self.w = self.add_weight(
            shape=(
                input_shape[-1],
                self.units
            ),
            initializer="random_normal",
            trainable=True
        )

        self.b = self.add_weight(
            shape=(
                self.units,
            ),
            initializer="zeros",
            trainable=True
        )

    def call(
        self,
        inputs
    ):

        return tf.matmul(
            inputs,
            self.w
        ) + self.b

Usage:

layer = CustomDense(
    64
)

output = layer(
    inputs
)

๐Ÿงฎ Custom Dense Layer Mathematics

The custom layer implements:

[ y = Wx+b ]

If an activation is included:

[ y=f(Wx+b) ]

This demonstrates an important principle:

A neural-network layer is fundamentally a parameterized mathematical transformation.


๐Ÿง  add_weight()

Keras provides:

self.add_weight()

for creating layer variables.

Example:

self.kernel = self.add_weight(
    shape=(input_dim, units),
    initializer="glorot_uniform",
    trainable=True
)

This allows Keras to track the variable automatically.


๐Ÿ” Trainable Variables

A layer exposes trainable parameters through:

layer.trainable_weights

For a custom dense layer:

for weight in layer.trainable_weights:

    print(
        weight.name,
        weight.shape
    )

๐Ÿง  Trainable vs Non-Trainable Variables

A layer may contain:

Trainable Variables
        +
Non-Trainable Variables

Trainable variables are updated during optimization.

Non-trainable variables are not updated by gradient descent.

flowchart TD

    LAYER["Keras Layer"]

    LAYER --> TRAIN["Trainable Variables"]
    LAYER --> NON["Non-Trainable Variables"]

    TRAIN --> GRAD["Gradients"]
    GRAD --> UPDATE["Optimizer Update"]

    NON --> STATE["Layer State"]

๐Ÿงช Non-Trainable Weight

self.running_mean = self.add_weight(
    shape=(features,),
    initializer="zeros",
    trainable=False
)

This variable is tracked by Keras but is not optimized through backpropagation.


๐Ÿง  Custom Layer with Activation

class CustomDense(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        units,
        activation=None
    ):

        super().__init__()

        self.units = units
        self.activation = tf.keras.activations.get(
            activation
        )

    def build(
        self,
        input_shape
    ):

        self.w = self.add_weight(
            shape=(
                input_shape[-1],
                self.units
            ),
            initializer="glorot_uniform"
        )

        self.b = self.add_weight(
            shape=(self.units,),
            initializer="zeros"
        )

    def call(
        self,
        inputs
    ):

        output = tf.matmul(
            inputs,
            self.w
        ) + self.b

        if self.activation is not None:

            output = self.activation(
                output
            )

        return output

๐Ÿง  Why build() Is Useful

Consider:

layer = CustomDense(
    128
)

At this moment, the layer may not know:

Input Features

When the first input arrives:

Input Shape
      โ†“
build()
      โ†“
Create Weights

This makes custom layers reusable across different input dimensions.


๐Ÿ— Layer Lifecycle

flowchart TD

    CREATE["Create Layer"]

    INIT["__init__()"]

    INPUT["First Input"]

    BUILD["build()"]

    CALL["call()"]

    OUTPUT["Output"]

    CREATE --> INIT
    INIT --> INPUT
    INPUT --> BUILD
    BUILD --> CALL
    CALL --> OUTPUT

For subsequent compatible calls, Keras generally reuses the already-created weights rather than rebuilding them.


๐Ÿง  Custom Layer Example โ€” Scaling

A simple custom layer can implement:

[ y=\alpha x ]

class ScalingLayer(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        scale
    ):

        super().__init__()

        self.scale = scale

    def call(
        self,
        inputs
    ):

        return inputs * self.scale

Usage:

layer = ScalingLayer(
    0.5
)

output = layer(
    inputs
)

๐Ÿง  Custom Layer Example โ€” Learnable Scaling

The scale itself can be trainable.

class LearnableScaling(
    tf.keras.layers.Layer
):

    def build(
        self,
        input_shape
    ):

        self.scale = self.add_weight(
            shape=(input_shape[-1],),
            initializer="ones",
            trainable=True
        )

    def call(
        self,
        inputs
    ):

        return inputs * self.scale

The model learns:

Which features should be amplified?
Which features should be reduced?

๐Ÿง  Custom Layer Example โ€” Residual Block

A residual block can be implemented as a custom layer.

class ResidualBlock(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        units
    ):

        super().__init__()

        self.dense1 = tf.keras.layers.Dense(
            units,
            activation="relu"
        )

        self.dense2 = tf.keras.layers.Dense(
            units
        )

        self.activation = tf.keras.layers.ReLU()

    def call(
        self,
        inputs
    ):

        x = self.dense1(
            inputs
        )

        x = self.dense2(
            x
        )

        x = x + inputs

        return self.activation(
            x
        )

Architecture:

flowchart TD

    INPUT["Input"]

    D1["Dense + ReLU"]

    D2["Dense"]

    ADD["Add"]

    RELU["ReLU"]

    INPUT --> D1
    D1 --> D2
    D2 --> ADD

    INPUT --> ADD

    ADD --> RELU

This pattern becomes important for:

  • ResNet
  • Deep MLPs
  • Transformer blocks
  • Advanced architectures

๐Ÿง  Custom Layer with Training Behavior

Some layers behave differently during training and inference.

Examples:

Dropout
Batch Normalization
Stochastic Layers
Augmentation Layers

The call() method can accept:

training=False

Example:

class CustomDropout(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        rate
    ):

        super().__init__()

        self.rate = rate

    def call(
        self,
        inputs,
        training=False
    ):

        if training:

            return tf.nn.dropout(
                inputs,
                rate=self.rate
            )

        return inputs

๐Ÿง  Training vs Inference

flowchart TD

    INPUT["Input"]

    TRAIN{"training=True?"}

    TRAIN -->|Yes| TRAINPATH["Training Behavior"]

    TRAIN -->|No| INFER["Inference Behavior"]

    TRAINPATH --> OUTPUT["Output"]
    INFER --> OUTPUT

The model should be explicit about behavior that differs between training and inference.


๐Ÿง  Custom Models

A custom layer represents a reusable transformation.

A custom model generally represents the larger architecture.

For example:

Custom Layer
    โ†“
Reusable Component

Custom Model
    โ†“
Complete Network

๐Ÿงช Custom Keras Model

class MyModel(
    tf.keras.Model
):

    def __init__(
        self
    ):

        super().__init__()

        self.dense1 = tf.keras.layers.Dense(
            128,
            activation="relu"
        )

        self.dense2 = tf.keras.layers.Dense(
            64,
            activation="relu"
        )

        self.output_layer = tf.keras.layers.Dense(
            10,
            activation="softmax"
        )

    def call(
        self,
        inputs,
        training=False
    ):

        x = self.dense1(
            inputs
        )

        x = self.dense2(
            x
        )

        return self.output_layer(
            x
        )

Usage:

model = MyModel()

๐Ÿง  Layer vs Model

Custom Layer Custom Model
Reusable computation block Complete architecture
Usually smaller Usually larger
Extends Layer Extends Model
Can contain other layers Can contain many layers
Used inside models Represents the model itself
Defines transformation Defines overall forward computation

๐Ÿ— Custom Model Architecture

flowchart LR

    INPUT["Input"]

    L1["Custom / Standard Layer"]

    L2["Custom / Standard Layer"]

    HEAD["Output Head"]

    OUTPUT["Prediction"]

    INPUT --> L1
    L1 --> L2
    L2 --> HEAD
    HEAD --> OUTPUT

๐Ÿง  call() Defines Forward Pass

For a custom model:

def call(
    self,
    inputs,
    training=False
):

defines how data moves through the network.

Conceptually:

[ \hat{y}=f_\theta(x) ]

where:

x      = input
ฮธ      = model parameters
fฮธ     = neural network
ลท      = prediction

๐Ÿง  Automatic Differentiation

Training requires gradients.

TensorFlow provides:

tf.GradientTape

for automatic differentiation.

Conceptually:

Forward Pass
      โ†“
Loss
      โ†“
GradientTape
      โ†“
Gradients
      โ†“
Optimizer
      โ†“
Updated Weights
flowchart TD

    INPUT["Input"]

    MODEL["Model"]

    PRED["Prediction"]

    LOSS["Loss"]

    TAPE["GradientTape"]

    GRAD["Gradients"]

    OPT["Optimizer"]

    UPDATE["Updated Parameters"]

    INPUT --> MODEL
    MODEL --> PRED
    PRED --> LOSS

    LOSS --> TAPE
    TAPE --> GRAD
    GRAD --> OPT
    OPT --> UPDATE

    UPDATE --> MODEL

๐Ÿงฎ Gradient Computation

Suppose:

[ L=f(w) ]

The gradient is:

[ \frac{\partial L}{\partial w} ]

The optimizer then updates:

[ w \leftarrow w-\eta \frac{\partial L}{\partial w} ]


๐Ÿงช Basic GradientTape

x = tf.Variable(
    3.0
)

with tf.GradientTape() as tape:

    y = x ** 2

gradient = tape.gradient(
    y,
    x
)

print(
    gradient
)

Because:

[ y=x^2 ]

we have:

[ \frac{dy}{dx}=2x ]

For:

[ x=3 ]

the gradient is:

6

๐Ÿง  GradientTape Workflow

flowchart LR

    VARIABLE["Trainable Variable"]

    TAPE["GradientTape"]

    FORWARD["Forward Computation"]

    LOSS["Loss"]

    GRAD["Gradient"]

    VARIABLE --> TAPE
    TAPE --> FORWARD
    FORWARD --> LOSS
    LOSS --> GRAD

The tape records operations performed within its context so gradients can later be computed.


๐Ÿงช Simple Linear Regression with GradientTape

import tensorflow as tf


w = tf.Variable(
    0.0
)

b = tf.Variable(
    0.0
)


x = tf.constant(
    [1.0, 2.0, 3.0]
)

y_true = tf.constant(
    [2.0, 4.0, 6.0]
)


with tf.GradientTape() as tape:

    y_pred = w * x + b

    loss = tf.reduce_mean(
        tf.square(
            y_true - y_pred
        )
    )


gradients = tape.gradient(
    loss,
    [w, b]
)

print(
    gradients
)

This demonstrates the core mechanism behind neural-network training.


๐Ÿง  Applying Gradients

Once gradients are computed:

optimizer.apply_gradients(
    zip(
        gradients,
        variables
    )
)

The complete process becomes:

Forward
   โ†“
Loss
   โ†“
GradientTape
   โ†“
Gradients
   โ†“
Optimizer
   โ†“
Parameter Update

๐Ÿงช Manual Training Step

optimizer = tf.keras.optimizers.Adam(
    learning_rate=0.001
)


with tf.GradientTape() as tape:

    predictions = model(
        x_batch,
        training=True
    )

    loss = loss_fn(
        y_batch,
        predictions
    )


gradients = tape.gradient(
    loss,
    model.trainable_variables
)


optimizer.apply_gradients(
    zip(
        gradients,
        model.trainable_variables
    )
)

This is the fundamental custom training step.


๐Ÿง  Custom Training Loop

A custom training loop repeatedly executes the training step.

for epoch in range(
    epochs
):

    for x_batch, y_batch in train_dataset:

        with tf.GradientTape() as tape:

            predictions = model(
                x_batch,
                training=True
            )

            loss = loss_fn(
                y_batch,
                predictions
            )

        gradients = tape.gradient(
            loss,
            model.trainable_variables
        )

        optimizer.apply_gradients(
            zip(
                gradients,
                model.trainable_variables
            )
        )

๐Ÿ— Custom Training Loop

flowchart TD

    DATA["Training Dataset"]

    EPOCH["Epoch"]

    BATCH["Batch"]

    FORWARD["Forward Pass"]

    LOSS["Loss"]

    GRAD["GradientTape"]

    UPDATE["Optimizer Update"]

    NEXT["Next Batch"]

    DATA --> EPOCH
    EPOCH --> BATCH
    BATCH --> FORWARD
    FORWARD --> LOSS
    LOSS --> GRAD
    GRAD --> UPDATE
    UPDATE --> NEXT
    NEXT --> BATCH

๐Ÿง  Why Use Custom Training Loops?

Use custom training loops when you need control over:

  • Multiple losses
  • Multiple optimizers
  • Custom gradient manipulation
  • Gradient accumulation
  • Adversarial training
  • Contrastive learning
  • Custom optimization logic
  • Complex training schedules
  • Specialized research architectures

For ordinary supervised learning, model.fit() is often simpler.


๐Ÿง  model.fit() vs Custom Loop

model.fit() Custom Training Loop
High-level Low-level
Less code More code
Standard workflows Specialized workflows
Built-in callbacks Manual control
Built-in metrics Manual or hybrid
Easier maintenance More responsibility
Faster development Greater flexibility

๐Ÿง  Keras fit() Internals

Conceptually, model.fit() performs something similar to:

for each epoch:

    for each batch:

        forward pass
        calculate loss
        calculate gradients
        update parameters
        update metrics

The difference is that Keras handles the training infrastructure for you.


๐Ÿง  Overriding train_step()

Keras provides a useful middle ground.

Instead of implementing the entire training loop, override:

train_step()

This allows custom training logic while still using:

model.fit()

๐Ÿงช Custom train_step()

class CustomModel(
    tf.keras.Model
):

    def train_step(
        self,
        data
    ):

        x, y = data

        with tf.GradientTape() as tape:

            y_pred = self(
                x,
                training=True
            )

            loss = self.compute_loss(
                x=x,
                y=y,
                y_pred=y_pred
            )

        gradients = tape.gradient(
            loss,
            self.trainable_variables
        )

        self.optimizer.apply_gradients(
            zip(
                gradients,
                self.trainable_variables
            )
        )

        return {
            "loss": loss
        }

This provides custom training behavior without completely abandoning the Keras training framework.


๐Ÿง  Three Levels of Training Control

flowchart TD

    HIGH["High Control Spectrum"]

    HIGH --> FIT["model.fit()"]

    FIT --> TRAINSTEP["Override train_step()"]

    TRAINSTEP --> LOOP["Fully Custom Training Loop"]

    LOOP --> LOW["Maximum Control"]

Think of the choices as:

Standard Training
      โ†“
model.fit()

Custom Training Behavior
      โ†“
Custom train_step()

Maximum Control
      โ†“
Custom Training Loop

๐Ÿง  Custom Loss Functions

Keras supports custom losses.

A loss function generally has:

True Values
+
Predictions
      โ†“
Loss

Example:

def custom_mse(
    y_true,
    y_pred
):

    error = y_true - y_pred

    return tf.reduce_mean(
        tf.square(error)
    )

๐Ÿงฎ Mean Squared Error

[ MSE = \frac{1}{n} \sum_{i=1}^{n} (y_i-\hat{y}_i)^2 ]

Custom losses are useful when standard loss functions do not match the training objective.


๐Ÿง  Custom Loss with Additional Terms

A custom loss can combine multiple objectives.

For example:

[ L = L_{task} + \lambda L_{regularization} ]

This is common in:

  • Representation learning
  • Multi-task learning
  • Regularized models
  • Research architectures

๐Ÿง  Custom Regularization

A layer can add a regularization term using:

self.add_loss(...)

Example:

class RegularizedLayer(
    tf.keras.layers.Layer
):

    def call(
        self,
        inputs
    ):

        penalty = tf.reduce_mean(
            tf.square(inputs)
        )

        self.add_loss(
            1e-4 * penalty
        )

        return inputs

Keras then incorporates this additional loss during training.


๐Ÿง  add_loss()

Conceptually:

Main Task Loss
       +
Additional Layer Loss
       โ†“
Total Loss
flowchart TD

    TASK["Task Loss"]

    REG["Regularization Loss"]

    TASK --> TOTAL["Total Loss"]

    REG --> TOTAL

    TOTAL --> GRAD["Gradient"]

    GRAD --> UPDATE["Optimizer"]

๐Ÿง  Custom Metrics

Metrics measure model behavior without necessarily controlling optimization.

Example:

class MeanAbsoluteErrorMetric(
    tf.keras.metrics.Metric
):

    def __init__(
        self,
        name="mae",
        **kwargs
    ):

        super().__init__(
            name=name,
            **kwargs
        )

        self.total = self.add_weight(
            name="total",
            initializer="zeros"
        )

        self.count = self.add_weight(
            name="count",
            initializer="zeros"
        )

    def update_state(
        self,
        y_true,
        y_pred,
        sample_weight=None
    ):

        error = tf.abs(
            y_true - y_pred
        )

        self.total.assign_add(
            tf.reduce_sum(error)
        )

        self.count.assign_add(
            tf.cast(
                tf.size(error),
                tf.float32
            )
        )

    def result(self):

        return (
            self.total /
            self.count
        )

    def reset_state(self):

        self.total.assign(
            0.0
        )

        self.count.assign(
            0.0
        )

๐Ÿง  Loss vs Metric

Keep the distinction clear:

Loss
 โ†“
Optimization Target

Metric
 โ†“
Measurement / Monitoring

For example:

Loss = Cross Entropy
Metric = Accuracy

The optimizer uses gradients derived from the loss.


๐Ÿง  Multiple Optimizers

Custom training loops can use different optimizers for different components.

For example:

Generator
    โ†“
Optimizer A

Discriminator
    โ†“
Optimizer B

This pattern is important in:

  • GANs
  • Adversarial training
  • Multi-network architectures

๐Ÿง  Multiple Gradient Tapes

Some architectures require separate gradient computations.

Conceptually:

flowchart TD

    INPUT["Input"]

    MODEL_A["Model A"]

    MODEL_B["Model B"]

    LOSS_A["Loss A"]

    LOSS_B["Loss B"]

    GRAD_A["Gradients A"]

    GRAD_B["Gradients B"]

    OPT_A["Optimizer A"]

    OPT_B["Optimizer B"]

    INPUT --> MODEL_A
    INPUT --> MODEL_B

    MODEL_A --> LOSS_A
    MODEL_B --> LOSS_B

    LOSS_A --> GRAD_A
    LOSS_B --> GRAD_B

    GRAD_A --> OPT_A
    GRAD_B --> OPT_B

This level of control is difficult to express using a simple standard training workflow.


๐Ÿง  Gradient Clipping in Custom Loops

Gradients can be clipped before applying them.

gradients = tape.gradient(
    loss,
    model.trainable_variables
)

gradients, _ = tf.clip_by_global_norm(
    gradients,
    1.0
)

optimizer.apply_gradients(
    zip(
        gradients,
        model.trainable_variables
    )
)

The workflow becomes:

Loss
 โ†“
Gradients
 โ†“
Gradient Clipping
 โ†“
Optimizer
 โ†“
Parameter Update

๐Ÿง  Gradient Accumulation

Gradient accumulation can simulate a larger effective batch size when GPU memory is limited.

Conceptually:

Batch 1 โ†’ Gradients
Batch 2 โ†’ Accumulate
Batch 3 โ†’ Accumulate
Batch 4 โ†’ Accumulate
          โ†“
      Update Weights
flowchart LR

    B1["Batch 1"]
    B2["Batch 2"]
    B3["Batch 3"]
    B4["Batch 4"]

    B1 --> G["Gradient Accumulator"]
    B2 --> G
    B3 --> G
    B4 --> G

    G --> UPDATE["Optimizer Update"]

This is useful for large models where the desired effective batch size does not fit into GPU memory.


๐Ÿงช Simplified Gradient Accumulation

accumulated_gradients = [
    tf.zeros_like(variable)
    for variable
    in model.trainable_variables
]


for x_batch, y_batch in train_dataset:

    with tf.GradientTape() as tape:

        predictions = model(
            x_batch,
            training=True
        )

        loss = loss_fn(
            y_batch,
            predictions
        )

    gradients = tape.gradient(
        loss,
        model.trainable_variables
    )

    accumulated_gradients = [

        acc + grad

        for acc, grad
        in zip(
            accumulated_gradients,
            gradients
        )
    ]

In production, the accumulated gradients should be normalized appropriately and reset after the optimizer update.


๐Ÿง  tf.function

TensorFlow can convert Python functions into optimized TensorFlow graphs using:

@tf.function

Example:

@tf.function
def square(
    x
):

    return x * x

This can improve execution efficiency for repeated TensorFlow operations.


๐Ÿง  Eager Execution vs Graph Execution

Eager Execution

Operations execute immediately.

Python Code
    โ†“
Tensor Operation
    โ†“
Immediate Result

Graph Execution

Operations are traced into a computation graph.

Python Function
      โ†“
TensorFlow Graph
      โ†“
Optimized Execution
flowchart LR

    EAGER["Eager Execution"]
    GRAPH["Graph Execution"]

    EAGER --> DEBUG["Easy Debugging"]

    GRAPH --> OPT["Graph Optimization"]

๐Ÿง  tf.function Example

@tf.function
def train_step(
    model,
    optimizer,
    loss_fn,
    x,
    y
):

    with tf.GradientTape() as tape:

        predictions = model(
            x,
            training=True
        )

        loss = loss_fn(
            y,
            predictions
        )

    gradients = tape.gradient(
        loss,
        model.trainable_variables
    )

    optimizer.apply_gradients(
        zip(
            gradients,
            model.trainable_variables
        )
    )

    return loss

This can be used inside a custom training loop.


โš  tf.function Considerations

tf.function can improve performance, but developers should understand:

  • Tracing
  • Retracing
  • Python side effects
  • Tensor vs Python values
  • Dynamic control flow
  • Input signatures

Avoid repeatedly creating different Python signatures that cause unnecessary retracing.


๐Ÿง  Custom Validation Loop

A custom validation loop can be implemented without gradient computation.

for x_batch, y_batch in validation_dataset:

    predictions = model(
        x_batch,
        training=False
    )

    loss = loss_fn(
        y_batch,
        predictions
    )

Notice:

training=False

and:

No GradientTape

because validation does not update model parameters.


๐Ÿง  Training vs Validation

flowchart TD

    BATCH["Batch"]

    BATCH --> TRAIN{"Training?"}

    TRAIN -->|Yes| FORWARD["Forward Pass"]
    FORWARD --> LOSS["Loss"]
    LOSS --> TAPE["GradientTape"]
    TAPE --> GRAD["Gradients"]
    GRAD --> UPDATE["Optimizer Update"]

    TRAIN -->|No| EVAL["Forward Pass Only"]
    EVAL --> METRIC["Metrics"]

๐Ÿง  Custom Training Step with Metrics

A more complete training step can track metrics.

loss_metric = tf.keras.metrics.Mean(
    name="loss"
)


for x_batch, y_batch in train_dataset:

    with tf.GradientTape() as tape:

        predictions = model(
            x_batch,
            training=True
        )

        loss = loss_fn(
            y_batch,
            predictions
        )

    gradients = tape.gradient(
        loss,
        model.trainable_variables
    )

    optimizer.apply_gradients(
        zip(
            gradients,
            model.trainable_variables
        )
    )

    loss_metric.update_state(
        loss
    )

At the end:

print(
    loss_metric.result()
)

๐Ÿง  Complete Custom Training Architecture

flowchart TD

    DATA["Training Dataset"]

    EPOCH["Epoch"]

    BATCH["Batch"]

    MODEL["Custom Model"]

    LOSS["Custom / Standard Loss"]

    TAPE["GradientTape"]

    GRAD["Gradients"]

    CLIP["Gradient Processing"]

    OPT["Optimizer"]

    METRIC["Metrics"]

    UPDATE["Updated Parameters"]

    DATA --> EPOCH
    EPOCH --> BATCH

    BATCH --> MODEL
    MODEL --> LOSS

    LOSS --> TAPE
    TAPE --> GRAD

    GRAD --> CLIP
    CLIP --> OPT

    OPT --> UPDATE
    UPDATE --> MODEL

    LOSS --> METRIC
    MODEL --> METRIC

๐Ÿง  Custom Training Loop Design Principles

A good custom training loop should have clear separation between:

Data Loading
      โ†“
Forward Pass
      โ†“
Loss Calculation
      โ†“
Gradient Calculation
      โ†“
Gradient Processing
      โ†“
Parameter Update
      โ†“
Metrics
      โ†“
Checkpointing

Avoid putting all of this logic into one giant function.


A production-oriented implementation could look like:

training/
โ”‚
โ”œโ”€โ”€ trainer.py
โ”œโ”€โ”€ losses.py
โ”œโ”€โ”€ metrics.py
โ”œโ”€โ”€ optimizers.py
โ”œโ”€โ”€ schedulers.py
โ””โ”€โ”€ callbacks.py

models/
โ”‚
โ”œโ”€โ”€ layers.py
โ”œโ”€โ”€ blocks.py
โ””โ”€โ”€ model.py

data/
โ”‚
โ”œโ”€โ”€ dataset.py
โ””โ”€โ”€ preprocessing.py

This keeps training infrastructure separate from model architecture.


๐Ÿง  Custom Layer Testing

Custom layers should be tested independently.

For example:

layer = CustomDense(
    32
)

x = tf.random.normal(
    (8, 16)
)

y = layer(
    x
)

assert y.shape == (
    8,
    32
)

Test:

Output Shape
Data Type
Trainable Variables
Numerical Behavior
Serialization
Training Behavior
Inference Behavior

๐Ÿงช Numerical Gradient Verification

For complex custom operations, gradient correctness matters.

You can inspect gradients:

with tf.GradientTape() as tape:

    output = layer(x)

gradient = tape.gradient(
    output,
    layer.trainable_variables
)

Check for:

None
NaN
Inf
Unexpected Magnitude

โš  Common Custom-Layer Errors

Common mistakes include:

  • Creating weights in the wrong place
  • Creating weights repeatedly inside call()
  • Forgetting trainable=True
  • Returning tensors with unexpected shapes
  • Ignoring the training argument
  • Mixing NumPy operations with TensorFlow tensors inside differentiable computation
  • Creating Python-side state that TensorFlow cannot track
  • Failing to implement serialization configuration
  • Assuming eager execution behavior is identical under tf.function

๐Ÿง  Serialization of Custom Layers

Custom layers should support serialization when they need to be saved and reloaded.

Example:

class ScalingLayer(
    tf.keras.layers.Layer
):

    def __init__(
        self,
        scale,
        **kwargs
    ):

        super().__init__(
            **kwargs
        )

        self.scale = scale

    def call(
        self,
        inputs
    ):

        return inputs * self.scale

    def get_config(
        self
    ):

        config = super().get_config()

        config.update({
            "scale": self.scale
        })

        return config

This allows Keras to reconstruct the layer configuration.


๐Ÿง  Why get_config() Matters

Without proper serialization:

Saved Model
      โ†“
Load Model
      โ†“
Custom Layer Unknown

With serialization support:

Saved Model
      โ†“
Configuration
      โ†“
Custom Layer Reconstruction
      โ†“
Loaded Model

For production systems, model portability is an important consideration.


๐Ÿง  Custom Models and Serialization

Custom models should similarly be designed with serialization in mind.

Keep configuration values explicit:

class CustomModel(
    tf.keras.Model
):

    def __init__(
        self,
        units,
        **kwargs
    ):

        super().__init__(
            **kwargs
        )

        self.units = units

        self.dense = tf.keras.layers.Dense(
            units
        )

Configuration should not be hidden inside arbitrary runtime state.


๐Ÿข Enterprise Perspective

Custom layers and training loops provide powerful capabilities, but they also increase engineering responsibility.

With standard Keras:

Less Code
+
More Framework Management

With custom training:

More Control
+
More Engineering Responsibility

The production engineering team must now consider:

Correctness
+
Testing
+
Serialization
+
Reproducibility
+
Performance
+
Numerical Stability
+
Monitoring
+
Distributed Execution

Production Insight

Use the highest-level abstraction that satisfies the requirement.

A practical decision is:

Standard Model
    โ†“
model.fit()

Need Custom Model Component
    โ†“
Custom Layer

Need Custom Training Behavior
    โ†“
Override train_step()

Need Maximum Training Control
    โ†“
Custom Training Loop

Do not implement a custom training loop simply because you can.

Every additional layer of customization becomes part of the production system that must be tested, maintained, monitored, and upgraded.


๐Ÿง  Custom Training Decision Guide

flowchart TD

    START["Training Requirement"]

    START --> STANDARD{"Standard Supervised Training?"}

    STANDARD -->|Yes| FIT["Use model.fit()"]

    STANDARD -->|No| COMPONENT{"Need Custom Layer / Operation?"}

    COMPONENT -->|Yes| LAYER["Create Custom Layer"]

    COMPONENT -->|No| TRAINSTEP{"Need Custom Training Logic?"}

    TRAINSTEP -->|Moderate| OVERRIDE["Override train_step()"]

    TRAINSTEP -->|Extensive| LOOP["Custom Training Loop"]

๐Ÿงช Practical Exercise 1 โ€” Custom Dense Layer

Implement a layer that performs:

[ y=f(Wx+b) ]

Requirements:

Custom weights
Custom bias
Optional activation
Correct trainable variables

Test it against:

tf.keras.layers.Dense

for compatible initialization and configuration.


๐Ÿงช Practical Exercise 2 โ€” Custom Residual Block

Implement:

Input
  โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ†“              โ”‚
Dense            โ”‚
  โ†“              โ”‚
Dense            โ”‚
  โ†“              โ”‚
  โ””โ”€โ”€โ”€โ”€ Add โ—„โ”€โ”€โ”€โ”€โ”˜
         โ†“
       ReLU

Verify:

Input Shape
Output Shape
Parameter Count
Gradient Flow

๐Ÿงช Practical Exercise 3 โ€” Custom Loss

Implement:

def custom_loss(
    y_true,
    y_pred
):
    ...

Compare it with:

tf.keras.losses.MeanSquaredError()

Verify that the outputs are numerically consistent for the same inputs.


๐Ÿงช Practical Exercise 4 โ€” Custom Training Loop

Build:

Dataset
 โ†“
Model
 โ†“
Loss
 โ†“
GradientTape
 โ†“
Gradients
 โ†“
AdamW
 โ†“
Update

Track:

Training Loss
Validation Loss
Accuracy

๐Ÿงช Practical Exercise 5 โ€” Custom train_step()

Create a custom model that overrides:

train_step()

but still trains using:

model.fit()

Compare the implementation with a normal Keras model.


๐Ÿงช Practical Exercise 6 โ€” Gradient Clipping

Modify the custom training loop to apply:

tf.clip_by_global_norm()

Compare:

Without Clipping
With Clipping

Track gradient norms and training stability.


๐Ÿงช Practical Exercise 7 โ€” Gradient Accumulation

Implement gradient accumulation:

Batch 1
Batch 2
Batch 3
Batch 4
    โ†“
Optimizer Update

Compare it against:

Large Physical Batch

with approximately the same effective batch size.


๐Ÿงช Practical Exercise 8 โ€” Training vs Inference

Create a custom layer whose behavior changes according to:

training=True

and:

training=False

Verify that:

Training Output

and:

Inference Output

behave as expected.


๐Ÿง  Interview Questions

Beginner

1. Why would you create a custom Keras layer?

To implement reusable computations or transformations that are not adequately represented by existing Keras layers.

2. What are the main methods in a custom layer?

Commonly:

__init__()
build()
call()

3. What does build() do?

It is commonly used to create weights whose shapes depend on the input shape.

4. What does call() do?

It defines the forward computation performed by the layer.

5. What is GradientTape?

tf.GradientTape records differentiable TensorFlow operations so gradients can be computed automatically.


Intermediate

6. What is the difference between a custom layer and custom model?

A custom layer generally represents a reusable computation block, while a custom model represents a larger network architecture and its forward pass.

7. What is a custom training loop?

It is a manually controlled training process where the developer explicitly performs forward computation, loss calculation, gradient calculation, and parameter updates.

8. Why use a custom training loop?

When standard model.fit() does not provide sufficient control over the training algorithm.

9. What is train_step()?

It is a Keras extension point that allows custom training logic while continuing to use the broader model.fit() framework.

10. What is the difference between a loss and a metric?

A loss provides the optimization objective, while a metric is primarily used to measure and monitor model behavior.

11. What is gradient clipping?

Gradient clipping limits gradient magnitude to improve numerical stability and control exploding gradients.


Advanced

12. Why should weights generally not be created inside call()?

Because call() may execute repeatedly. Creating weights there can lead to repeated variable creation and incorrect parameter tracking.

13. Why is training passed to call()?

Some layers need different behavior during training and inference, such as Dropout and Batch Normalization.

14. What is the advantage of overriding train_step() over writing a completely custom loop?

It allows custom training logic while retaining Keras features such as fit(), callbacks, progress reporting, and other training infrastructure.

15. When would you use multiple optimizers?

When different model components require independent optimization, such as generator/discriminator systems or specialized multi-network training.

16. Why is gradient accumulation useful?

It allows a larger effective batch size when the desired batch cannot fit into available device memory.

17. What are the risks of custom training loops?

They introduce additional complexity around:

Correctness
Metrics
Checkpointing
Distributed Training
Mixed Precision
Serialization
Reproducibility

18. Why is serialization important for custom layers?

A production model must be reconstructable after deployment or loading. Custom layer configuration must therefore be preserved correctly.

19. When should you avoid a custom training loop?

When standard Keras training already satisfies the requirements. Unnecessary customization increases maintenance and testing complexity.

20. How would you design a production custom training system?

Separate:

Model
Layer
Loss
Optimizer
Training Step
Metrics
Checkpointing
Configuration
Data Pipeline

and ensure each component is testable and observable.


๐Ÿ“Œ Key Takeaways

  • Keras provides high-level APIs but also allows low-level customization.
  • Custom layers extend tf.keras.layers.Layer.
  • __init__() generally stores configuration.
  • build() is useful for creating input-dependent weights.
  • call() defines the forward computation.
  • add_weight() allows Keras to track variables correctly.
  • Trainable variables participate in gradient-based optimization.
  • Non-trainable variables represent state that is not optimized through gradients.
  • Custom layers can implement reusable mathematical operations.
  • Custom models extend tf.keras.Model.
  • A model's call() defines its forward pass.
  • tf.GradientTape provides automatic differentiation.
  • A custom training step generally consists of forward pass, loss calculation, gradient computation, and optimizer update.
  • Custom training loops provide maximum control.
  • Overriding train_step() provides a useful middle ground between model.fit() and a fully custom loop.
  • Custom losses allow specialized optimization objectives.
  • add_loss() can incorporate additional regularization or auxiliary objectives.
  • Custom metrics provide specialized monitoring.
  • Gradient clipping can be incorporated into custom training.
  • Gradient accumulation can increase effective batch size without increasing physical batch size.
  • tf.function can convert Python functions into optimized TensorFlow graphs.
  • Training and inference may require different layer behavior.
  • Custom components should be independently tested.
  • Custom layers and models should support serialization when required.
  • Custom training loops should be used only when the additional control justifies the additional complexity.
  • Production Deep Learning requires balancing flexibility with maintainability, reproducibility, observability, and operational reliability.

๐Ÿ“š Further Reading

Continue with:

The next chapter moves to the second major Deep Learning framework and builds the equivalent foundations in PyTorch, including tensors, autograd, model construction, and GPU execution.


โžก๏ธ Next Chapter

16. PyTorch Fundamentals and Tensors


Enterprise AI Engineering Handbook
Building Production-Grade Enterprise AI Systems โ€” One Chapter at a Time.