TensorFlow Custom Layers

Custom layers let you define entirely new learnable building blocks for neural networks. When no built-in Keras layer does exactly what you need — a novel attention mechanism, a domain-specific normalization, a research paper's non-standard transformation — you write a custom layer. Custom layers integrate seamlessly with Keras models, get tracked by GradientTape, appear in model.summary(), and save and load with the model.

The Lego Analogy

Keras built-in layers (Dense, Conv2D, LSTM) are standard Lego bricks. They fit together in countless configurations. But some models need a custom-shaped piece that the standard set does not include. You can design that custom brick — your custom layer — and it connects with all the standard pieces perfectly.

Anatomy of a Custom Layer

import tensorflow as tf

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

    def __init__(self, units, activation=None, **kwargs):
        """
        __init__: Store configuration. Do NOT create weights here.
        **kwargs passes arguments like name, dtype to the parent class.
        """
        super().__init__(**kwargs)
        self.units = units
        self.activation = tf.keras.activations.get(activation)

    def build(self, input_shape):
        """
        build: Create weights. Called automatically the first time
        the layer receives input. input_shape tells you the shape of
        the incoming tensor so you can size your weights correctly.
        """
        self.kernel = self.add_weight(
            name='kernel',
            shape=(input_shape[-1], self.units),
            initializer='glorot_uniform',
            trainable=True
        )
        self.bias = self.add_weight(
            name='bias',
            shape=(self.units,),
            initializer='zeros',
            trainable=True
        )
        super().build(input_shape)   # Mark the layer as built

    def call(self, inputs, training=None):
        """
        call: Define the forward computation. This runs every time
        data passes through the layer. training=None allows
        training-specific behavior (like Dropout).
        """
        output = tf.matmul(inputs, self.kernel) + self.bias
        if self.activation is not None:
            output = self.activation(output)
        return output

    def get_config(self):
        """
        get_config: Returns the configuration so Keras can
        serialize and deserialize this layer (needed for model saving).
        """
        config = super().get_config()
        config.update({'units': self.units,
                       'activation': tf.keras.activations.serialize(self.activation)})
        return config

Using a Custom Layer in a Model

# Custom layers plug into Sequential and Functional API just like built-ins
model = tf.keras.Sequential([
    MyLayer(128, activation='relu', input_shape=(784,), name='custom_dense_1'),
    MyLayer(64, activation='relu', name='custom_dense_2'),
    tf.keras.layers.Dense(10, activation='softmax')  # Mix with built-in layers
])

model.summary()
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])
model.fit(x_train, y_train, epochs=10)

Custom Layer: Learnable Scaling

class ScaledDense(tf.keras.layers.Layer):
    """Dense layer with a learnable per-neuron scale factor."""

    def __init__(self, units, **kwargs):
        super().__init__(**kwargs)
        self.units = units

    def build(self, input_shape):
        self.kernel = self.add_weight(
            'kernel', shape=(input_shape[-1], self.units),
            initializer='glorot_uniform'
        )
        self.bias = self.add_weight(
            'bias', shape=(self.units,), initializer='zeros'
        )
        # Extra learnable scale per output neuron
        self.scale = self.add_weight(
            'scale', shape=(self.units,),
            initializer='ones'   # Start at 1.0 (no scaling initially)
        )
        super().build(input_shape)

    def call(self, inputs):
        output = tf.matmul(inputs, self.kernel) + self.bias
        return output * self.scale   # Element-wise scale

Custom Layer: Squeeze-and-Excitation Block

Squeeze-and-Excitation is a real research technique used in EfficientNet. It weighs each channel of a feature map based on how important that channel is globally — learned during training.

class SqueezeExcitation(tf.keras.layers.Layer):
    """Recalibrates channel-wise feature responses."""

    def __init__(self, reduction_ratio=4, **kwargs):
        super().__init__(**kwargs)
        self.reduction_ratio = reduction_ratio

    def build(self, input_shape):
        channels = input_shape[-1]
        reduced  = max(1, channels // self.reduction_ratio)

        self.squeeze = tf.keras.layers.GlobalAveragePooling2D()
        self.fc1 = tf.keras.layers.Dense(reduced, activation='relu')
        self.fc2 = tf.keras.layers.Dense(channels, activation='sigmoid')
        super().build(input_shape)

    def call(self, inputs):
        # Squeeze: global average pooling → channel vector
        se = self.squeeze(inputs)                # (batch, channels)
        # Excitation: two Dense layers learn channel importance
        se = self.fc1(se)                        # (batch, channels/r)
        se = self.fc2(se)                        # (batch, channels)
        # Reshape for broadcasting
        se = tf.reshape(se, [-1, 1, 1, inputs.shape[-1]])
        # Recalibrate: multiply each channel by its importance score
        return inputs * se

# Use in a CNN
x = tf.keras.layers.Conv2D(64, 3, padding='same', activation='relu')(input_layer)
x = SqueezeExcitation(reduction_ratio=4)(x)

Custom Layer With training Flag

class StochasticDepth(tf.keras.layers.Layer):
    """Randomly skip this layer during training (survival probability)."""

    def __init__(self, survival_prob=0.8, **kwargs):
        super().__init__(**kwargs)
        self.survival_prob = survival_prob

    def call(self, inputs, training=None):
        if not training:
            return inputs   # Always use layer at inference
        # During training: skip layer with probability (1 - survival_prob)
        if tf.random.uniform(()) > self.survival_prob:
            return tf.zeros_like(inputs)   # Skip this layer
        return inputs / self.survival_prob  # Scale to maintain expected value

Non-Trainable State in Custom Layers

class RunningStatsLayer(tf.keras.layers.Layer):
    """Tracks the running mean of activations (non-trainable)."""

    def build(self, input_shape):
        self.running_mean = self.add_weight(
            'running_mean',
            shape=(input_shape[-1],),
            initializer='zeros',
            trainable=False    # Optimizer will NOT update this
        )
        super().build(input_shape)

    def call(self, inputs, training=None):
        if training:
            # Update running mean with exponential moving average
            batch_mean = tf.reduce_mean(inputs, axis=0)
            self.running_mean.assign(0.99 * self.running_mean + 0.01 * batch_mean)
        return inputs   # Pass through unchanged; just tracking stats

Saving Models With Custom Layers

# Save works automatically
model.save('custom_layer_model.keras')

# Loading requires passing the custom class
loaded = tf.keras.models.load_model(
    'custom_layer_model.keras',
    custom_objects={'MyLayer': MyLayer,
                    'SqueezeExcitation': SqueezeExcitation}
)

# Or register the class so Keras finds it automatically
@tf.keras.utils.register_keras_serializable(package='MyPackage')
class MyLayer(tf.keras.layers.Layer):
    ...

# Registered layers load without custom_objects
loaded = tf.keras.models.load_model('custom_layer_model.keras')

Debugging Custom Layers

# Test a custom layer in isolation before using it in a model
layer = SqueezeExcitation(reduction_ratio=4)

test_input = tf.random.normal([2, 8, 8, 64])   # batch=2, H=8, W=8, C=64
output = layer(test_input)

print("Input shape: ", test_input.shape)   # (2, 8, 8, 64)
print("Output shape:", output.shape)       # (2, 8, 8, 64) — same shape
print("Output range: ", output.numpy().min(), "–", output.numpy().max())

Custom layers put the full power of TensorFlow mathematics at your disposal while keeping your code organized and compatible with the Keras ecosystem. Any transformation you can express mathematically can become a reusable, trainable, saveable layer. The next topic covers Autoencoders — a powerful architecture that learns compressed representations of data without any labels, enabling denoising, anomaly detection, and generative modeling.

Leave a Comment

Your email address will not be published. Required fields are marked *