Beginner

Build a Machine Learning Workflow with Keras TensorFlow 2.0

machine · workflow · keras · tensorflow · 2.0 · deep · neural · networks · data · science · models · layers · custom · model · reference · unsupervised · callbacks · convolutional · datas...

Table of Contents


Module 1: Course Overview

This course, Build a Machine Learning Workflow with Keras TensorFlow 2.0, covers how different APIs in Keras lend themselves to different use cases.

In response to the rise of other deep learning frameworks, Keras has transformed itself into a tightly connected part of the TensorFlow 2.0 ecosystem, even as it serves its original purpose of being a high-level, easy-to-use API.

What you will learn:

  • How different APIs in Keras lend themselves to different use cases.
  • Sequential models consisting of layers stacked on top of one another — simple and long-supported by Keras.
  • Functional API — designed to create more complex, callable models, enabling non-sequential topologies with multiple inputs and outputs.
  • Custom layers and model subclassing — fine-grained control over layer definitions, including trainable and non-trainable weights, deferred shape inference, and recursive composition.
  • Model subclassing is a great way of implementing the forward pass imperatively, particularly useful with eager execution.

By the end, you will have the skills and knowledge to choose between the many different model-building strategies available in Keras.


Module 2: Understanding Keras Models and Layers

Prerequisites and Course Outline

This module introduces the basic building blocks in Keras: layers and models.

Topics covered:

  • Differences between supervised and unsupervised learning
  • The Keras high-level API with a TensorFlow 2.0 back end
  • Sequential models (linear stack of layers) and the Functional API
  • Options to serialize models out to disk and reload them

Prerequisites:

  • Comfortable programming in Python using Jupyter Notebooks
  • Basic understanding of machine learning algorithms (regression and classification)
  • Familiarity with deep learning concepts: neurons → layers → models
  • Basic knowledge of TensorFlow 2.0

Required libraries:

pip install tensorflow pandas numpy matplotlib scikit-learn seaborn
pip install git+https://github.com/tensorflow/docs
pip install opencv-python

Introducing Keras

Keras is a high-level neural networks API written in Python. Originally capable of running on top of multiple back ends (TensorFlow, CNTK, Theano), Keras is now primarily used as an integral part of the TensorFlow 2.0 ecosystem.

Key points:

  • Keras is an interface, not a standalone deep learning framework.
  • Keras is a central part of the tightly connected TensorFlow 2.0 ecosystem and covers every part of the machine learning workflow.
  • TensorFlow 2.0 is an open-source machine learning platform that solves any problem requiring differential gradients.
  • TensorFlow executes computation graphs that can scale to multiple devices.
  • TensorFlow 1 required creating static computation graphs without an intuitive high-level API — Keras fixes this.
  • When you install TensorFlow 2.0, an implementation of the Keras API spec is included; no additional installation is needed.
flowchart TD
    TF2["TensorFlow 2.0 Ecosystem"] --> Keras["Keras High-Level API"]
    TF2 --> TFData["tf.data Pipelines"]
    TF2 --> TFLite["TFLite / TFServing"]
    Keras --> Sequential["Sequential API"]
    Keras --> Functional["Functional API"]
    Keras --> Subclassing["Model Subclassing"]
    Sequential --> Layers["Built-in Layers\n(Dense, Conv2D, LSTM, etc.)"]
    Functional --> Layers
    Subclassing --> Layers

Supervised Learning

Machine learning models can be divided into two broad categories: supervised and unsupervised learning.

Supervised learning is characterized by:

  • Input variable $x$ and output variable $y$
  • Learning the mapping function $y = f(x)$
  • The goal is to approximate $f$ so that for new values of $x$, we can predict $y$
  • Training data includes a labeled corpus used to correct the algorithm

The training process (deep learning):

  1. Feed training data into the neural network (forward pass)
  2. Get a prediction (classification or regression output)
  3. Compute the loss function (cost function) comparing prediction to the actual label
  4. Use the loss to update model parameters (backpropagation + optimizer)
  5. Repeat until the model converges

Regression predicts a continuous value (e.g., insurance charges). Classification predicts a discrete category (e.g., normal vs. abnormal spine).

flowchart LR
    Input["Input Data (x)"] --> Model["Neural Network Model"]
    Model --> Output["Prediction ŷ"]
    Output --> Loss["Loss Function\nL(y, ŷ)"]
    Loss --> Backprop["Backpropagation"]
    Backprop --> Optimizer["Optimizer\n(Adam, RMSprop, SGD)"]
    Optimizer --> Model
    Labels["True Labels (y)"] --> Loss

Unsupervised Learning

With unsupervised machine learning, you do not have $y$ variables. The training data does not include labels.

Key characteristics:

  • Only input data $x$ is available — no output/target labels
  • The model must self-discover patterns and structure in the data
  • No tweaking of model parameters using a label-based cost function during the classic supervised sense
  • Algorithms model the underlying structure to learn more about the data

Popular unsupervised learning techniques:

  • Clustering — finds logical groupings in data (e.g., K-Means)
  • Autoencoding — finds latent factors that exist in data (dimensionality reduction)

The key insight: unsupervised algorithms are driven purely by the intrinsic structure of the data, not by external labels.

flowchart LR
    Input["Input Data (x)\nNo Labels"] --> Model["Unsupervised Model\n(Autoencoder / Clustering)"]
    Model --> Patterns["Discovered Patterns\n& Latent Representations"]
    Patterns --> Insights["Insights / Reconstruction\n/ Compressed Encoding"]

Sequential Models

Neural networks are models made up of layers. The two core data structures in Keras are layers and models.

Neurons → Layers → Models

Sequential models are the most intuitive and straightforward way to build a neural network:

  • A linear stack of layers
  • Output of one layer is the input to the next
  • Best suited for simple, feed-forward architectures
model = tf.keras.Sequential([
    layers.Dense(32, activation='relu', input_shape=[n_features]),
    layers.Dense(64, activation='relu'),
    layers.Dense(1)
])
flowchart LR
    Input["Input Layer\n(n features)"] --> D1["Dense Layer\n32 neurons, ReLU"]
    D1 --> D2["Dense Layer\n64 neurons, ReLU"]
    D2 --> Output["Output Layer\n1 neuron (regression)"]

Other model types available in Keras:

  • Sequential API — simple linear stack
  • Functional API — complex topologies (multiple inputs/outputs, shared layers)
  • Model subclassing — fully customizable forward pass

The Functional API

As use cases become more complex, model topology may also become complex. The Functional API is used to build complex model topologies that cannot be constructed using the sequential API.

Use the Functional API when you need:

  • Multiple inputs to the model
  • Multiple output labels
  • Shared layers across multiple neural network branches
  • Non-sequential data flows (e.g., skip connections)

Key difference from Sequential API:

  • Sequential API is inherently object-oriented (layers as objects)
  • Functional API is more functional in nature — every layer is a callable that accepts an input tensor and returns an output tensor
  • Keras models themselves can also be called as callables on any tensor

Usage pattern:

inputs = tf.keras.Input(shape=(n_features,))
x = layers.Dense(16, activation='relu')(inputs)
x = layers.Dropout(0.3)(x)
x = layers.Dense(8, activation='relu')(x)
predictions = layers.Dense(1, activation='sigmoid')(x)

model = tf.keras.Model(inputs=inputs, outputs=predictions)
flowchart TD
    I["Input\ntf.keras.Input(shape=(n,))"] --> D1["Dense(16, relu)"]
    D1 --> DO["Dropout(0.3)"]
    DO --> D2["Dense(8, relu)"]
    D2 --> Out["Dense(1, sigmoid)\n(Binary Classification)"]
    Out --> Model["tf.keras.Model(inputs=I, outputs=Out)"]

Saving and Loading Models

Once you have a fully trained model, you can serialize it to permanent storage. Keras with TensorFlow 2.0 makes it easy to save and reload models.

Components of a model you can save:

ComponentDescription
ArchitectureLayer configuration and interconnections
WeightsTrained model parameters
OptimizerOptimizer state
Losses & metricsTraining configuration

Saving options:

OptionWhat is savedUse case
model.save()Architecture + weights + optimizerFull model reload
model.to_json()Architecture onlyRecreate structure without weights
model.save_weights()Weights onlyResume training or transfer

Formats:

  • TensorFlow SavedModel format (default, recommended for TF) — full model
  • HDF5 format (.h5) — lighter alternative, preferred for multi-backend Keras
# Save full model
model.save("./my_models/model_name")

# Save architecture as JSON
json_config = model.to_json()
with open('./my_models/model_config.json', 'w') as f:
    f.write(json_config)

# Save weights only (TF checkpoint format)
model.save_weights("./my_models/model_weights")

# Save weights in HDF5 format
model.save_weights("./my_models/model_weights.h5", save_format="h5")

# Load full model
loaded_model = tf.keras.models.load_model("./my_models/model_name")

# Load from JSON + weights
with open('./my_models/model_config.json', 'r') as f:
    model_json = json.load(f)
model = tf.keras.models.model_from_json(json.dumps(model_json))
model.load_weights("./my_models/model_weights")

Environment Setup

Checking versions and installing dependencies:

# Check Python version (3.7+ recommended)
python --version

# Upgrade pip
python -m pip install --upgrade pip

# Install libraries
pip install pandas
pip install numpy
pip install matplotlib
pip install scikit-learn
pip install tensorflow

Verify Keras version in Python:

import tensorflow as tf
from tensorflow import keras

keras.__version__

Module 3: Building Regression and Classification Models

Demo: Exploring and Processing the Insurance Dataset

In this demo, we build and train a regression model using the Keras Sequential API to predict medical insurance charges.

Dataset: Medical Cost Personal Datasets (insurance.csv)

Fields:

  • age — age of primary beneficiary
  • sex — contractor gender (female/male)
  • bmi — Body mass index (ideally 18.5–24.9)
  • children — number of dependents covered
  • smoker — smoking status (yes/no)
  • region — US residential area (northeast, southeast, southwest, northwest)
  • charges — individual medical costs billed by health insurance (target)
import pprint
import json

import pandas as pd
import matplotlib.pyplot as plt

from sklearn.preprocessing import StandardScaler
from sklearn.metrics import r2_score

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

import tensorflow_docs as tfdocs
import tensorflow_docs.modeling
import tensorflow_docs.plots
# Load dataset
data = pd.read_csv('datasets/insurance.csv')
data.sample(10)
data.shape
data.isna().sum()
data[['age', 'bmi', 'charges']].describe()
data['sex'].value_counts()
data['smoker'].value_counts()
data['region'].value_counts()

Visualization:

# Distribution of charges
plt.figure(figsize=(10, 8))
data.boxplot('charges')
plt.show()

plt.figure(figsize=(10, 8))
data['charges'].plot.kde()
plt.show()

# Scatter plots
plt.figure(figsize=(10, 8))
plt.scatter(data['age'], data['charges'], s=200)
plt.xlabel('Age', fontsize=20)
plt.ylabel('Medical costs billed by health insurance', fontsize=20)
plt.show()

plt.figure(figsize=(10, 8))
plt.scatter(data['bmi'], data['charges'], s=200)
plt.xlabel('Body mass index', fontsize=20)
plt.ylabel('Medical costs billed by health insurance', fontsize=20)
plt.show()

Correlation heatmap:

import seaborn as sns

data_corr = data.corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(data_corr, annot=True)
plt.show()

Feature engineering:

features = data.drop('charges', axis=1)
target = data[['charges']]

# Separate categorical and numeric features
categorical_features = features[['sex', 'smoker', 'region']].copy()
numeric_features = features.drop(['sex', 'smoker', 'region'], axis=1)

# Encode categorical features
gender_dict = {'female': 0, 'male': 1}
categorical_features['sex'].replace(gender_dict, inplace=True)

smoker_dict = {'no': 0, 'yes': 1}
categorical_features['smoker'].replace(smoker_dict, inplace=True)

# One-hot encode region
categorical_features = pd.get_dummies(categorical_features, columns=['region'])

# Standardize numeric features
standardScaler = StandardScaler()
numeric_features = pd.DataFrame(
    standardScaler.fit_transform(numeric_features),
    columns=numeric_features.columns,
    index=numeric_features.index
)

# Concatenate processed features
processed_features = pd.concat([numeric_features, categorical_features], axis=1, sort=False)

# Save processed data
processed_data = pd.concat([processed_features, target], axis=1, sort=False)
processed_data.to_csv('datasets/insurance_processed.csv', index=False)

Train/test split:

from sklearn.model_selection import train_test_split

x_train, x_test, y_train, y_test = train_test_split(
    processed_features, target,
    test_size=0.2,
    random_state=1
)

x_train.shape  # ~1068 records
x_test.shape   # ~268 records

Demo: Training a Simple Sequential Model

Building a regression model with relu activation:

def build_model():
    model = tf.keras.Sequential([
        layers.Dense(32, activation='relu', input_shape=[len(x_train.keys())]),
        layers.Dense(64, activation='relu'),
        layers.Dense(1)  # Single output for regression
    ])

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

    model.compile(loss='mse',
                  optimizer=optimizer,
                  metrics=['mae', 'mse'])
    return model

Note: The output layer has 1 unit because regression predicts a single continuous value. Weights are initialized with the Glorot uniform initializer by default; biases start at 0.

Visualize model architecture:

model_relu_64 = build_model()
model_relu_64.summary()

keras.utils.plot_model(model_relu_64, 'model_relu_64.png')
keras.utils.plot_model(model_relu_64, 'model_relu_64_shapes.png', show_shapes=True)

Train the model:

n_epochs = 1000

training_hist = model_relu_64.fit(
    x_train, y_train,
    epochs=n_epochs,
    validation_split=0.2,
    verbose=True
)

Plot training metrics:

training_hist_df = pd.DataFrame(training_hist.history)
training_hist_df['epoch'] = training_hist.epoch
training_hist_df.tail()

plotter = tfdocs.plots.HistoryPlotter(smoothing_std=2)

plt.figure(figsize=(10, 8))
plotter.plot({'Basic': training_hist}, metric="mae")
plt.ylabel('MAE [Charges]')
plt.show()

plt.figure(figsize=(10, 8))
plotter.plot({'Basic': training_hist}, metric="mse")
plt.ylabel('MSE [Charges^2]')
plt.show()

Evaluate on test data:

y_pred = model_relu_64.predict(x_test).flatten()

plt.figure(figsize=(10, 8))
plt.scatter(y_test, y_pred, s=200, c='darkblue')
plt.xlabel('Actual charges values')
plt.ylabel('Predicted charges values')
plt.show()

r2_score(y_test, y_pred)

Demo: Configuring Training Behavior Using Callbacks

Building a model with ELU activation and Dropout:

ELU (Exponential Linear Unit) tends to converge faster and produce more accurate results than ReLU. It also mitigates neuron saturation by keeping neurons active. Dropout intentionally turns off 20% of neurons during training, forcing remaining neurons to learn more — reducing overfitting.

def build_model():
    model = tf.keras.Sequential([
        layers.Dense(32, activation='elu', input_shape=[len(x_train.keys())]),
        layers.Dropout(0.2),
        layers.Dense(64, activation='elu'),
        layers.Dense(1)
    ])

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

    model.compile(loss='mse',
                  optimizer=optimizer,
                  metrics=['mae', 'mse'])
    return model

Training with EpochDots callback:

model_elu_64 = build_model()

training_hist = model_elu_64.fit(
    x_train, y_train,
    epochs=n_epochs,
    validation_split=0.2,
    verbose=False,
    callbacks=[tfdocs.modeling.EpochDots()]
)

Training with EarlyStopping callback:

A callback is an object passed to a model to extend its behavior during training. EarlyStopping monitors a metric (e.g., val_loss) and stops training when there is no improvement after patience epochs — preventing overfitting and saving compute.

model_relu_with_ES = build_model()

early_stop = tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=5)

training_history = model_relu_with_ES.fit(
    x_train, y_train,
    epochs=n_epochs,
    validation_split=0.2,
    verbose=False,
    callbacks=[early_stop, tfdocs.modeling.EpochDots()]
)
plt.figure(figsize=(10, 8))
plotter.plot({'Early Stopping': training_history}, metric="mae")
plt.ylabel('MAE [charges]')
plt.show()

model_relu_with_ES.evaluate(x_test, y_test)

y_pred = model_relu_with_ES.predict(x_test).flatten()
r2_score(y_test, y_pred)
flowchart TD
    Train["model.fit()\nTraining Loop"] --> EpochEnd["End of Epoch"]
    EpochEnd --> Monitor["Monitor val_loss"]
    Monitor --> Improved{"Improved?"}
    Improved -- Yes --> Continue["Continue Training"]
    Improved -- No --> Patience{"patience\nexhausted?"}
    Patience -- No --> Continue
    Patience -- Yes --> Stop["EarlyStopping\nStop Training"]
    Continue --> EpochEnd

Demo: Saving Model Architecture and Weights

# Save weights as TF checkpoint
model_relu_with_ES.save_weights("./my_models/relu_64_weights")

# Save weights in HDF5 format
model_relu_with_ES.save_weights("./my_models/relu_64_weights.h5", save_format="h5")

# Save model architecture as JSON
relu_64_json = model_relu_with_ES.to_json()
pprint.pprint(json.loads(relu_64_json))

with open('./my_models/relu_64_config.json', 'w') as outfile:
    outfile.write(relu_64_json)

# Save the entire model (architecture + weights + optimizer)
model_relu_with_ES.save("./my_models/relu_64_config_weights")

The TensorFlow checkpoint format splits weights into shards. An index file maps model weights to the appropriate shard. For simple single-machine training, there will be one shard.


Demo: Loading Saved Models

Starting from a fresh notebook:

import pprint
import json

import pandas as pd
from sklearn.metrics import r2_score

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

import tensorflow_docs as tfdocs
import tensorflow_docs.modeling
import tensorflow_docs.plots
# Load preprocessed data
processed_data = pd.read_csv('datasets/insurance_processed.csv')
processed_features = processed_data.drop('charges', axis=1)
target = processed_data[['charges']]

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(
    processed_features, target, test_size=0.2, random_state=1
)

Load architecture from JSON:

with open('./my_models/relu_64_config.json', 'r') as infile:
    model_json = json.load(infile)

pprint.pprint(model_json)

# Reconstruct model from JSON config
model = tf.keras.models.model_from_json(json.dumps(model_json))
model.summary()

Predict without weights (random initialization):

y_pred_after_loading = model.predict(x_test)
r2_score(y_test, y_pred_after_loading)  # Poor performance — no trained weights

Load trained weights:

model.load_weights("./my_models/relu_64_weights")

y_pred_after_loading = model.predict(x_test)
r2_score(y_test, y_pred_after_loading)  # Good performance with trained weights

Load full model (architecture + weights + optimizer):

loaded_model = tf.keras.models.load_model("./my_models/relu_64_config_weights")
loaded_model.summary()

y_pred_after_loading = loaded_model.predict(x_test)
r2_score(y_test, y_pred_after_loading)

Demo: Exploring and Processing the Spine Dataset

In this demo, we build a binary classification model using the Keras Functional API. The Lower Back Pain Symptoms Dataset classifies spines as Abnormal or Normal.

import os
import datetime

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score, precision_score, recall_score

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

tf.keras.backend.clear_session()  # Destroy current graph and create a fresh session

clear_session() is useful when building multiple models in the same Python kernel — ensures you start with a clean state.

spine_data = pd.read_csv('datasets/Dataset_spine.csv',
                         skiprows=1,
                         names=['pelvic_incidence', 'pelvic tilt',
                                'lumbar_lordosis_angle', 'sacral_slope',
                                'pelvic_radius', 'degree_spondylolisthesis',
                                'pelvic_slope', 'direct_tilt', 'thoracic_slope',
                                'cervical_tilt', 'sacrum_angle', 'scoliosis_slope',
                                'class_att'])

spine_data = spine_data.sample(frac=1).reset_index(drop=True)
spine_data.head().T
spine_data.shape       # 310 rows
spine_data.columns
spine_data['class_att'].unique()   # ['Abnormal', 'Normal']
spine_data.describe().transpose()

Visualization:

plt.figure(figsize=(8, 6))
sns.countplot('class_att', data=spine_data)
plt.xlabel('class_att', fontsize=20)
plt.ylabel('count', fontsize=20)
plt.show()

plt.figure(figsize=(8, 6))
sns.boxplot('class_att', 'pelvic_incidence', data=spine_data)
plt.show()

Preprocessing:

# Encode target
class_att = {'Abnormal': 0, 'Normal': 1}
spine_data['class_att'].replace(class_att, inplace=True)

features = spine_data.drop('class_att', axis=1)
target = spine_data[['class_att']]

# Scale features
standardScaler = StandardScaler()
scaled_features = pd.DataFrame(
    standardScaler.fit_transform(features),
    columns=features.columns,
    index=features.index
)

Train/validation/test split:

from sklearn.model_selection import train_test_split

x_train, x_test, y_train, y_test = train_test_split(
    scaled_features, target, test_size=0.15, random_state=10
)

x_train, x_val, y_train, y_val = train_test_split(
    x_train, y_train, test_size=0.15, random_state=10
)

x_train.shape, x_val.shape, x_test.shape

Demo: Build and Train Model Using the Functional API

The Functional API provides flexibility to design models with non-linear topology, shared layers, multiple inputs, and multiple outputs.

def build_and_compile_model():

    inputs = tf.keras.Input(shape=(x_train.shape[1],))

    x = layers.Dense(16, activation='relu')(inputs)
    x = layers.Dropout(0.3)(x)
    x = layers.Dense(8, activation='relu')(x)

    predictions = layers.Dense(1, activation='sigmoid')(x)

    model = tf.keras.Model(inputs=inputs, outputs=predictions)
    model.summary()

    model.compile(
        optimizer=tf.keras.optimizers.RMSprop(0.001),
        loss=tf.keras.losses.BinaryCrossentropy(),
        metrics=[
            'accuracy',
            tf.keras.metrics.Precision(0.5),
            tf.keras.metrics.Recall(0.5),
        ]
    )
    return model

Sigmoid activation in the output layer maps predictions to a probability score [0, 1]. A threshold (default 0.5) is then used to determine the binary class.

BinaryCrossentropy is the standard loss function for binary classification.

Visualize model:

model = build_and_compile_model()
keras.utils.plot_model(model, 'model_classification_shapes.png', show_shapes=True)

Train using tf.data.Dataset:

dataset_train = tf.data.Dataset.from_tensor_slices((x_train.values, y_train.values))
dataset_train = dataset_train.batch(16)
dataset_train.shuffle(128)

dataset_val = tf.data.Dataset.from_tensor_slices((x_val.values, y_val.values))
dataset_val = dataset_val.batch(16)

num_epochs = 10

model = build_and_compile_model()
training_history = model.fit(dataset_train, epochs=num_epochs, validation_data=dataset_val)

Plot training metrics:

train_acc = training_history.history['accuracy']
train_loss = training_history.history['loss']
precision = training_history.history['precision_1']
recall = training_history.history['recall_1']
epochs_range = range(num_epochs)

plt.figure(figsize=(14, 8))

plt.subplot(1, 2, 1)
plt.plot(epochs_range, train_acc, label='Training Accuracy')
plt.plot(epochs_range, train_loss, label='Training Loss')
plt.title('Accuracy and Loss')
plt.legend()

plt.subplot(1, 2, 2)
plt.plot(epochs_range, precision, label='Precision')
plt.plot(epochs_range, recall, label='Recall')
plt.title('Precision and Recall')
plt.legend()

Prediction:

y_pred = model.predict(x_test)
y_pred[:10]

# Apply threshold
y_pred = np.where(y_pred >= 0.5, 1, y_pred)
y_pred = np.where(y_pred < 0.5, 0, y_pred)

# Evaluate
pred_results = pd.DataFrame({
    'y_test': y_test.values.flatten(),
    'y_pred': y_pred.flatten().astype('int32')
}, index=range(len(y_pred)))

pd.crosstab(pred_results.y_pred, pred_results.y_test)
accuracy_score(y_test, y_pred)
precision_score(y_test, y_pred)
recall_score(y_test, y_pred)

Demo: Checkpointing Models Using Callbacks

When training data is extremely large, the training process may run for a very long time. Checkpointing allows you to save the model at certain epochs so that if training fails, you can roll back to a checkpoint.

ckpt_path = 'my_models/spine_classfication'
ckpt_dir = os.path.dirname(ckpt_path)

ckpt_callback = tf.keras.callbacks.ModelCheckpoint(
    filepath=ckpt_path,
    save_weights_only=True,
    save_freq=3,       # Save every 3 epochs
    verbose=False
)

model = build_and_compile_model()

model.fit(
    dataset_train,
    epochs=num_epochs,
    validation_data=dataset_val,
    verbose=False,
    callbacks=[ckpt_callback]
)

Reload from checkpoint:

reloaded_model = build_and_compile_model()
reloaded_model.load_weights(ckpt_path)

# Predict with reloaded model
y_pred = reloaded_model.predict(x_test)
y_pred = np.where(y_pred >= 0.5, 1, y_pred)
y_pred = np.where(y_pred < 0.5, 0, y_pred)
accuracy_score(y_test, y_pred)

# Continue training from checkpoint
reloaded_model.fit(dataset_train, epochs=5, validation_data=dataset_val)

Demo: Monitoring Models Using TensorBoard

TensorBoard is TensorFlow’s visualization toolkit. It allows you to:

  • Track and visualize metrics (loss, accuracy) during training
  • Visualize the model computation graph
  • View weight distributions and histograms

TensorBoard is included when you install TensorFlow.

model = build_and_compile_model()

log_dir = "logs/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")

tensorboard_callback = tf.keras.callbacks.TensorBoard(
    log_dir=log_dir,
    histogram_freq=1   # Record histograms every epoch
)

model.fit(
    dataset_train,
    epochs=num_epochs,
    validation_data=dataset_val,
    verbose=False,
    callbacks=[tensorboard_callback]
)

View in Jupyter Notebook:

%load_ext tensorboard
%tensorboard --logdir logs/

View in a separate browser (recommended):

tensorboard --logdir logs/

Module 4: Building Image Classification Models

Drawbacks of Dense Neural Networks

Dense neural networks used for image classification suffer from parameter explosion.

Consider a simple 100×100 pixel grayscale image:

  • 10,000 input features (pixels)
  • First dense layer: 10,000 neurons
  • Connections between layer 1 and layer 2: $10{,}000 \times 10{,}000 = 100{,}000{,}000$ parameters

Training such a network requires enormous compute resources and is prone to overfitting. Dense neural networks also ignore the spatial structure of images — they treat each pixel independently, losing spatial correlations.

flowchart LR
    I["100×100 Image\n10,000 pixels"] --> L1["Dense Layer\n10,000 neurons"]
    L1 --> L2["Dense Layer\n10,000 neurons"]
    L2 --> L3["Output"]
    note["⚠️ 100M+ parameters\nParameter Explosion!"]

Introducing Convolutional Neural Networks

The human eye perceives images through local receptive fields:

  • Each neuron (receptor) in the eye reacts only to visual stimuli in its small local area
  • Individual neurons extract granular detail from their local field
  • Higher neurons aggregate local patterns into complex representations: pixels → lines → edges → objects

Convolutional Neural Networks (CNNs) are built to mimic this process:

  • They consider the spatial aspects of images
  • They aggregate information from local fields rather than looking at the entire image at once
  • Earlier layers extract granular details; later layers piece together higher-level features
flowchart TD
    Pixels["Pixel Level\n(Raw input)"] --> Lines["Lines & Edges\n(Conv Layer 1)"]
    Lines --> Shapes["Basic Shapes\n(Conv Layer 2)"]
    Shapes --> Objects["Objects / Parts\n(Conv Layer 3)"]
    Objects --> Class["Classification\n(Dense Layers)"]

Convolution

Convolution refers to a sliding window function applied to an input matrix.

  • Images are represented as matrices where each element is a pixel value
  • Grayscale images: single-channel, values 0–255 represent pixel intensity
  • RGB images: three channels (red, green, blue)

The convolutional kernel (filter):

  • A small matrix (e.g., 3×3) that slides over the input image
  • The kernel is applied element-wise in a sliding window fashion
  • Kernel weights are trainable parameters learned during training

Example: 3×3 kernel on a 6×6 image:

$$\text{output}[i,j] = \sum_{m}\sum_{n} \text{input}[i+m, j+n] \cdot \text{kernel}[m, n]$$

The output of this operation is called a feature map — it highlights specific features detected by the kernel.

Padding:

  • same padding: output is the same size as input (zeros are added around the border)
  • valid padding: output is smaller than input (no padding)

Stride:

  • Controls how many pixels the kernel moves at each step
  • Stride = 1: overlapping windows; Stride = 2: downsampling

Convolutional Layers

Convolutional layers extract information from local receptive fields by applying a convolutional kernel in a sliding window fashion.

Role in the network:

  • Initial convolutional layers focus on granular details (pixels → edges)
  • Successive layers aggregate into higher-level features (edges → objects)
  • The output of a convolutional layer is a stack of feature maps

Key parameters you configure:

  • Number of filters (feature maps depth)
  • Kernel size (e.g., 3×3, 5×5)
  • Padding (same or valid)
  • Stride
  • Activation function (typically ReLU)
# Example convolutional layer in Keras
layers.Conv2D(
    filters=32,
    kernel_size=(3, 3),
    padding='same',
    activation='relu',
    input_shape=(32, 32, 3)
)

Pooling Layers

Pooling layers perform subsampling of inputs, reducing spatial dimensions while retaining the most important information.

  • Pooling neurons have no weights or biases — they only apply an aggregation function
  • Common types: Max Pooling (most common), Average Pooling
  • Max pooling: extracts the maximum pixel value within the filter window
  • Stride is typically equal to pool size (no overlap)

Effect on feature maps:

  • Reduces height × width → makes the image smaller
  • Each pooling layer aggregates granular data from convolutional layers
  • Reduces computational load and provides translation invariance
Input: 4×4 matrix
Pool size: 2×2, Stride: 2

[1 2 | 3 4]           [Max(1,2,5,6)  Max(3,4,7,8)]   [6  8]
[5 6 | 7 8]    →      [Max(9,10,13,14) Max(11,12,15,16)] = [14 16]
[9 10| 11 12]
[13 14| 15 16]

CNN Architecture

The typical CNN architecture interleaves convolutional and pooling layers, followed by dense layers for final classification.

flowchart LR
    Input["Input Image\n32×32×3"] --> C1["Conv2D 16 filters\n3×3, ReLU, same"]
    C1 --> C2["Conv2D 32 filters\n3×3, ReLU, same"]
    C2 --> MP1["MaxPool2D\n2×2"]
    MP1 --> C3["Conv2D 64 filters\n3×3, ReLU"]
    C3 --> C4["Conv2D 128 filters\n3×3, ReLU"]
    C4 --> MP2["MaxPool2D\n2×2"]
    MP2 --> Flat["Flatten"]
    Flat --> D1["Dense 512, ReLU"]
    D1 --> D2["Dense 256, ReLU"]
    D2 --> Out["Dense 10, Softmax\n(10 classes)"]

What happens as data flows through:

  • Height × width decrease (pooling layers aggregate and compress)
  • Depth (number of feature maps) increases (more convolutional filters)
  • At the flatten layer, multi-dimensional feature maps become a 1D vector
  • Dense layers then perform the final classification

Demo: Loading and Preprocessing the Cifar10 Dataset

CIFAR-10: 60,000 images in 10 classes (airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck). 50,000 training + 10,000 test. Each image is 32×32 pixels, 3 channels (RGB).

!pip install opencv-python

from random import randint
import cv2
import os

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras.preprocessing.image import ImageDataGenerator
# Load built-in CIFAR-10 dataset
(train_images, train_labels), (test_images, test_labels) = \
    tf.keras.datasets.cifar10.load_data()

train_images.shape  # (50000, 32, 32, 3)
test_images.shape   # (10000, 32, 32, 3)
# Class lookup
lookup = [
    'Airplane', 'Automobile', 'Bird', 'Cat', 'Deer',
    'Dog', 'Frog', 'Horse', 'Ship', 'Truck'
]
# Display images helper
def show_img(images, labels, n_images):
    random_int = randint(0, labels.shape[0] - n_images)
    imgs, labels = (images[random_int:random_int + n_images],
                    labels[random_int:random_int + n_images])

    _, figs = plt.subplots(1, n_images, figsize=(n_images * 3, 3))

    for fig, img, label in zip(figs, imgs, labels):
        fig.imshow(img)
        ax = fig.axes
        ax.set_title(lookup[int(label)])
        ax.title.set_fontsize(20)
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)

show_img(train_images, train_labels, 5)

Save images to directories (for ImageDataGenerator.flow_from_directory):

train_dir = './datasets/train/'
test_dir = './datasets/test/'

i = 0
for img, label in zip(train_images, train_labels):
    path = train_dir + str(lookup[int(label)])
    cv2.imwrite(os.path.join(path, str(i) + '.jpeg'), img)
    i += 1
    cv2.waitKey(0)

i = 0
for img, label in zip(test_images, test_labels):
    path = test_dir + str(lookup[int(label)])
    cv2.imwrite(os.path.join(path, str(i) + '.jpeg'), img)
    i += 1
    cv2.waitKey(0)

Create data generators:

ImageDataGenerator handles:

  • Reading images from disk
  • Decoding image content
  • Converting to floating-point tensors
  • Rescaling pixel values from [0, 255] to [0, 1]
train_image_generator = ImageDataGenerator(rescale=1./255)
test_image_generator = ImageDataGenerator(rescale=1./255)

batch_size = 128

train_data_gen = train_image_generator.flow_from_directory(
    batch_size=batch_size,
    directory=train_dir,
    shuffle=True,
    target_size=(32, 32)
)

test_data_gen = test_image_generator.flow_from_directory(
    batch_size=batch_size,
    directory=test_dir,
    shuffle=True,
    target_size=(32, 32)
)

sample_batch = next(train_data_gen)
sample_batch[0].shape  # (128, 32, 32, 3)

Demo: Designing the Convolutional Neural Network

The CNN is built as a sequential model with two blocks of [Conv2D → Conv2D → MaxPool2D], followed by dense layers.

conv_model = tf.keras.models.Sequential([

    # Block 1
    layers.Conv2D(16, (3, 3), padding='same', activation='relu',
                  input_shape=sample_batch[0].shape[1:]),
    layers.Conv2D(32, (3, 3), padding='same', activation='relu'),
    layers.MaxPooling2D(pool_size=(2, 2)),

    # Block 2
    layers.Conv2D(64, (3, 3), padding='same', activation='relu'),
    layers.Conv2D(128, (3, 3), padding='same', activation='relu'),
    layers.MaxPooling2D(pool_size=(2, 2)),

    # Classification head
    layers.Flatten(),
    layers.Dense(512, activation='relu'),
    layers.Dense(256, activation='relu'),
    layers.Dense(10, activation='softmax')  # 10 classes
])

Compile the model:

conv_model.compile(
    optimizer='adam',
    loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
    metrics=['accuracy']
)

keras.utils.plot_model(conv_model, 'model_image_classification_shapes.png', show_shapes=True)

CategoricalCrossentropy is used for multi-class classification. Softmax in the output layer converts raw logits into a probability distribution across all 10 classes; the class with the highest probability is the predicted label.


Demo: Training and Prediction Using a CNN

training_hist = conv_model.fit(
    train_data_gen,
    epochs=5,
    steps_per_epoch=len(train_images) // batch_size,
    validation_data=test_data_gen,
    validation_steps=len(test_images) // batch_size
)

Training for 10 epochs takes approximately 40–60 minutes.

Plot accuracy and loss:

acc = training_hist.history['accuracy']
val_acc = training_hist.history['val_accuracy']
loss = training_hist.history['loss']
val_loss = training_hist.history['val_loss']
epochs_range = range(5)

plt.figure(figsize=(12, 8))

plt.subplot(1, 2, 1)
plt.plot(epochs_range, acc, label='Training Accuracy')
plt.plot(epochs_range, val_acc, label='Validation Accuracy')
plt.legend(loc='lower right')
plt.title('Training and Validation Accuracy')

plt.subplot(1, 2, 2)
plt.plot(epochs_range, loss, label='Training Loss')
plt.plot(epochs_range, val_loss, label='Validation Loss')
plt.legend(loc='lower left')
plt.title('Training and Validation Loss')
plt.show()

Prediction function:

from tensorflow.keras.preprocessing import image

def perform_test(model, img, label):
    plt.imshow(img)
    test_img = np.expand_dims(img, axis=0)
    result = model.predict(test_img)
    print('Actual label: ', lookup[int(label)])
    print('Predicted label: ', lookup[np.argmax(result)])

perform_test(conv_model, test_images[0], test_labels[0])
perform_test(conv_model, test_images[1], test_labels[1])
perform_test(conv_model, test_images[3], test_labels[3])

Demo: Using Image Transformations and Dropout

Data augmentation adds random perturbations to input images during training, forcing the model to learn more robust features and reducing overfitting. This is empirically shown to improve CNN performance.

# Horizontal flip
image_gen = ImageDataGenerator(rescale=1./255, horizontal_flip=True)
train_data_gen = image_gen.flow_from_directory(
    batch_size=batch_size, directory=train_dir,
    shuffle=True, target_size=(32, 32)
)

# Rotation
image_gen = ImageDataGenerator(rescale=1./255, rotation_range=60)
train_data_gen = image_gen.flow_from_directory(
    batch_size=batch_size, directory=train_dir,
    shuffle=True, target_size=(32, 32)
)

# Zoom
image_gen = ImageDataGenerator(rescale=1./255, zoom_range=0.5)
train_data_gen = image_gen.flow_from_directory(
    batch_size=batch_size, directory=train_dir,
    shuffle=True, target_size=(32, 32)
)

# Combined augmentation
image_gen = ImageDataGenerator(
    rescale=1./255,
    rotation_range=30,
    width_shift_range=.05,
    height_shift_range=.05,
    horizontal_flip=True,
    zoom_range=0.3
)

train_data_gen_aug = image_gen.flow_from_directory(
    batch_size=batch_size, directory=train_dir,
    shuffle=True, target_size=(32, 32)
)

CNN with Dropout layers:

conv_model_with_dropout = tf.keras.models.Sequential([

    # Block 1
    layers.Conv2D(16, (3, 3), padding='same', activation='relu',
                  input_shape=sample_batch[0].shape[1:]),
    layers.Conv2D(32, (3, 3), padding='same', activation='relu'),
    layers.MaxPooling2D(pool_size=(2, 2)),
    layers.Dropout(0.25),

    # Block 2
    layers.Conv2D(64, (3, 3), padding='same', activation='relu'),
    layers.Conv2D(128, (3, 3), padding='same', activation='relu'),
    layers.MaxPooling2D(pool_size=(2, 2)),
    layers.Dropout(0.25),

    # Classification head
    layers.Flatten(),
    layers.Dense(512, activation='relu'),
    layers.Dropout(0.25),
    layers.Dense(256, activation='relu'),
    layers.Dense(10, activation='softmax')
])

conv_model_with_dropout.compile(
    optimizer='adam',
    loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
    metrics=['accuracy']
)

training_hist = conv_model_with_dropout.fit(
    train_data_gen,
    epochs=5,
    steps_per_epoch=len(train_images) // batch_size,
    validation_data=test_data_gen,
    validation_steps=len(test_images) // batch_size
)
perform_test(conv_model_with_dropout, test_images[1], test_labels[1])
perform_test(conv_model_with_dropout, test_images[3], test_labels[3])

Module 5: Building Unsupervised Machine Learning Models

Supervised vs. Unsupervised Learning

At this point, we have practiced several supervised ML techniques (regression, classification) where labels in the training data are used to correct the algorithm.

Unsupervised learning — the model must learn the structure and patterns in the data without any training labels:

  • Only input data $x$ is available
  • Model the underlying structure to learn more about the data
  • Allow the algorithm to self-discover patterns

Popular unsupervised techniques:

  • Clustering — finds logical groupings in data
  • Autoencoding — finds latent factors in data (dimensionality reduction)

Autoencoders as Unsupervised Machine Learning

The key challenge: how do we train a model without labels?

Autoencoders solve this cleverly: they try to reconstruct the input at the output. This process:

  • Inherently uses the input itself as the training signal — no labels needed
  • Forces the model to learn efficient latent representations (compressed encodings)
  • Uses reconstruction loss (difference between input and reconstructed output) as feedback

Autoencoder structure:

flowchart LR
    Input["Input x\n(e.g., 784 pixels)"] --> Encoder["Encoder\n(Compression)"]
    Encoder --> Codings["Latent Space\n(Bottleneck, e.g., 16 dims)"]
    Codings --> Decoder["Decoder\n(Reconstruction)"]
    Decoder --> Output["Reconstructed x̂\n(same dim as input)"]
    Output --> Loss["Reconstruction Loss\nL(x, x̂) = MSE or BCE"]
    Loss --> Train["Update Encoder\n& Decoder Weights"]

Training loop:

  1. Feed image $x$ into the encoder → get latent code $z$
  2. Feed $z$ into the decoder → get reconstruction $\hat{x}$
  3. Compute reconstruction loss $L(x, \hat{x})$
  4. Backpropagate to update both encoder and decoder weights

Dimensionality Reduction Using Autoencoders

Trivial autoencoder: A single layer with as many neurons as input features simply passes input to output — no learning occurs.

Under-complete autoencoder: The key insight is to constrain the neural network to force dimensionality reduction:

  • The hidden (coding) layer has fewer neurons than the input
  • The network is forced to compress the input into a lower-dimensional representation
  • This forces the model to learn the most important features and discard redundant ones

The encoder-decoder architecture:

PartRole
EncoderCompresses input to a lower-dimensional latent code
Coding layerThe bottleneck — holds latent features
DecoderReconstructs the original input from the latent code

Important constraint: The input and output layers must have the same dimensionality as the input data, so the autoencoder can attempt to reconstruct it.

The decoder is a mirror image of the encoder. For every compression layer in the encoder, there is a corresponding expansion layer in the decoder.


Demo: Preprocessing Images for Autoencoding

Dataset: A-Z Handwritten Alphabets dataset — grayscale 28×28 images of handwritten letters (A–Z), ~400,000 samples.

from random import randint

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

import tensorflow as tf
from tensorflow.keras import layers
from tensorflow import keras
# Load dataset
alphabets_data = pd.read_csv('datasets/A_Z Handwritten Data.csv', header=None)
alphabets_data.shape  # (~372,450, 785)

# Sample 5% for manageable training
alphabets_data = alphabets_data.sample(frac=0.05).reset_index(drop=True)
alphabets_data.shape  # (~18,600, 785)
# Labels 0-25 → A-Z
lookup = { 0: 'A', 1: 'B', 2: 'C', 3: 'D',
           4: 'E', 5: 'F', 6: 'G', 7: 'H',
           8: 'I', 9: 'J', 10: 'K', 11: 'L',
          12: 'M', 13: 'N', 14: 'O', 15: 'P',
          16: 'Q', 17: 'R', 18: 'S', 19: 'T',
          20: 'U', 21: 'V', 22: 'W', 23: 'X',
          24: 'Y', 25: 'Z'}
# Separate features and labels
features = alphabets_data[alphabets_data.columns[1:]]
target = alphabets_data[0]

# Reshape to 28×28 images
features = features.values.reshape(len(features), 28, 28)

# Display an image
def show_image(features, actual_label):
    print("Actual label: ", lookup[actual_label])
    plt.imshow(features, cmap='Greys')
    plt.show()

show_image(features[10], target[10])
# Normalize pixel values to [0, 1]
features = features.astype(np.float32) / 255
# Train/test split — unsupervised: we only use features, no labels
from sklearn.model_selection import train_test_split

train_images, test_images, train_labels, test_labels = train_test_split(
    features, target, test_size=0.2
)

Demo: Reconstructing Images Using a Stacked Autoencoder

This autoencoder uses fully connected (dense) layers. The encoder compresses 784 pixels → 16 latent features; the decoder reconstructs 16 → 784 pixels.

Build stacked encoder:

stacked_encoder = tf.keras.models.Sequential([
    layers.Flatten(input_shape=[28, 28]),    # 784 inputs
    layers.Dense(64, activation="relu"),
    layers.Dense(32, activation="relu"),
    layers.Dense(16, activation="relu")      # Bottleneck: 16 latent features
])

stacked_encoder.summary()

Build stacked decoder (mirror of encoder):

stacked_decoder = tf.keras.models.Sequential([
    layers.Dense(32, activation="relu", input_shape=[16]),
    layers.Dense(64, activation="relu"),
    layers.Dense(28 * 28, activation="relu"),   # 784 outputs
    layers.Reshape([28, 28])                    # Reshape back to image
])

stacked_decoder.summary()

Combine into full autoencoder:

ae_model = tf.keras.models.Sequential([stacked_encoder, stacked_decoder])
ae_model.summary()

keras.utils.plot_model(ae_model, expand_nested=True, show_shapes=True)

ae_model.compile(
    loss='mean_squared_error',
    optimizer=tf.keras.optimizers.RMSprop(),
    metrics=['mse']
)

Train — note: input and target are both train_images:

# Key: unsupervised learning — we train to reconstruct the input
training_hist = ae_model.fit(
    train_images, train_images,   # Both X and y are the same!
    epochs=20
)

Visualize reconstructions:

def reconstruct_img(model, images, n_imgs):
    random_int = randint(0, images.shape[0] - n_imgs)
    reconstructions = model.predict(images[random_int:random_int + n_imgs])

    fig = plt.figure(figsize=(n_imgs * 3, 3))

    for img_index in range(n_imgs):
        # Original image
        plt.subplot(2, n_imgs, 1 + img_index)
        plt.imshow(images[random_int + img_index], cmap='Greys')

        # Reconstructed image
        plt.subplot(2, n_imgs, 1 + n_imgs + img_index)
        plt.imshow(reconstructions[img_index], cmap='Greys')

reconstruct_img(ae_model, test_images, 5)
flowchart LR
    subgraph Encoder
        F["Flatten\n784"] --> E1["Dense 64\nReLU"]
        E1 --> E2["Dense 32\nReLU"]
        E2 --> E3["Dense 16\nReLU\n(Bottleneck)"]
    end
    subgraph Decoder
        D1["Dense 32\nReLU"] --> D2["Dense 64\nReLU"]
        D2 --> D3["Dense 784\nReLU"]
        D3 --> R["Reshape\n28×28"]
    end
    E3 --> D1

Demo: Reconstructing Images Using a CNN-Based Autoencoder

CNNs are better suited for images than dense networks. A convolutional autoencoder uses Conv2D layers in the encoder and Conv2DTranspose (transposed convolution) layers in the decoder.

Conv2DTranspose is the inverse of Conv2D — it upsamples the feature maps to reconstruct the original spatial dimensions.

Build convolutional encoder:

conv_encoder = tf.keras.models.Sequential([
    layers.Reshape([28, 28, 1], input_shape=[28, 28]),  # Add channel dim

    layers.Conv2D(16, kernel_size=3, padding="SAME", activation="relu"),
    layers.MaxPool2D(pool_size=2),   # 28×28 → 14×14

    layers.Conv2D(32, kernel_size=3, padding="SAME", activation="relu"),
    layers.MaxPool2D(pool_size=2),   # 14×14 → 7×7

    layers.Conv2D(64, kernel_size=3, padding="SAME", activation="relu"),
    layers.MaxPool2D(pool_size=2)    # 7×7 → 3×3 (bottleneck: 3×3×64)
])

conv_encoder.summary()

Build convolutional decoder:

conv_decoder = tf.keras.models.Sequential([
    # Input: 3×3×64 (bottleneck)
    layers.Conv2DTranspose(32, kernel_size=3, strides=2,
                           padding="VALID", activation="relu",
                           input_shape=[3, 3, 64]),   # 3×3 → 7×7

    layers.Conv2DTranspose(16, kernel_size=3, strides=2,
                           padding="SAME", activation="relu"),  # 7×7 → 14×14

    layers.Conv2DTranspose(1, kernel_size=3, strides=2,
                           padding="SAME", activation="sigmoid"),  # 14×14 → 28×28

    layers.Reshape([28, 28])   # Remove channel dim
])

conv_decoder.summary()

Assemble and train:

conv_ae_model = tf.keras.models.Sequential([conv_encoder, conv_decoder])
conv_ae_model.summary()

conv_ae_model.compile(
    loss=tf.keras.losses.BinaryCrossentropy(),
    optimizer=tf.keras.optimizers.SGD(lr=1.0),
    metrics=['accuracy']
)

training_hist_convAE = conv_ae_model.fit(train_images, train_images, epochs=10)

reconstruct_img(conv_ae_model, test_images, 5)
flowchart LR
    subgraph ConvEncoder
        R1["Reshape 28×28→28×28×1"] --> C1["Conv2D 16\n3×3 SAME"]
        C1 --> MP1["MaxPool2D 2\n14×14×16"]
        MP1 --> C2["Conv2D 32\n3×3 SAME"]
        C2 --> MP2["MaxPool2D 2\n7×7×32"]
        MP2 --> C3["Conv2D 64\n3×3 SAME"]
        C3 --> MP3["MaxPool2D 2\n3×3×64 Bottleneck"]
    end
    subgraph ConvDecoder
        T1["Conv2DTranspose 32\n3×3 VALID stride=2 → 7×7"] --> T2["Conv2DTranspose 16\n3×3 SAME stride=2 → 14×14"]
        T2 --> T3["Conv2DTranspose 1\n3×3 SAME stride=2 → 28×28"]
        T3 --> R2["Reshape 28×28"]
    end
    MP3 --> T1

Module 6: Implementing Custom Layers and Models

Customizing Layers and Models

Keras allows very fine-grained customization of neural network design. The three main approaches:

ApproachFlexibilityUse Case
Sequential APILowSimple linear stack of layers
Functional APIMediumComplex topologies, multi-input/output
Model SubclassingHighFull control of forward pass, eager execution

Custom layers can be used with all three approaches.

What you can customize with tf.keras.layers.Layer:

  • Trainable weights (updated during training)
  • Non-trainable weights (remain constant during training)
  • Deferred weight creation (infer input shape at call time)
  • Recursive composition (layers containing layers)
  • Accumulated losses from sublayers

Model Subclassing and Custom Layers

Model subclassing gives complete control over how models are built:

  • Derive from tf.keras.Model
  • Define your own forward pass in call()
  • Models can encapsulate both built-in and custom layers
  • Supports eager execution — build-and-run vs. build-then-run

Custom layers:

  • Derive from tf.keras.layers.Layer
  • Define a call() method for the forward pass transformation
  • Optionally define build() for deferred weight initialization
flowchart TD
    KerasModel["tf.keras.Model\n(Base Class)"] --> CustomModel["CustomRegressionModel\n(Subclass)"]
    KerasLayer["tf.keras.layers.Layer\n(Base Class)"] --> CustomLayer["MyDense / SimpleLinear\n(Custom Layer)"]
    CustomModel --> CustomLayer
    CustomModel --> BuiltIn["Built-in Layers\n(Dense, Dropout, etc.)"]

Demo: Creating a Custom Layer

Weight initializers control how layer weights are initialized before training.

import tensorflow as tf
from tensorflow.keras import layers

# Zero initializer
zero_initializer = tf.zeros_initializer()
tensor_1 = zero_initializer(shape=(2, 3))
print(tensor_1)  # [[0. 0. 0.], [0. 0. 0.]]

# Random normal initializer
random_normal_initializer = tf.random_normal_initializer()
tensor_2 = random_normal_initializer(shape=(4, 5))
print(tensor_2)

Version 1: Custom layer with explicit weight creation in __init__:

class SimpleLinear(layers.Layer):

    def __init__(self, input_dim=16, units=8, **kwargs):
        super(SimpleLinear, self).__init__(**kwargs)

        w_init = tf.random_normal_initializer()
        self.w = tf.Variable(
            initial_value=w_init(shape=(input_dim, units), dtype='float32'),
            trainable=True
        )

        b_init = tf.zeros_initializer()
        self.b = tf.Variable(
            initial_value=b_init(shape=(units,), dtype='float32'),
            trainable=True
        )

    def call(self, input_tensor):
        return tf.matmul(input_tensor, self.w) + self.b
x = tf.ones((4, 3))
layer1 = SimpleLinear(input_dim=3, units=5)
y = layer1(x)

print(y)
layer1.weights
layer1.trainable_variables
layer1.non_trainable_variables
layer1.losses

Version 2: Using add_weight (cleaner API):

class SimpleLinear(layers.Layer):

    def __init__(self, input_dim=16, units=8, **kwargs):
        super(SimpleLinear, self).__init__(**kwargs)

        self.w = self.add_weight(
            shape=(input_dim, units),
            initializer='ones',
            trainable=True
        )
        self.b = self.add_weight(
            shape=(units,),
            initializer='ones',
            trainable=False   # Non-trainable weight
        )

    def call(self, input_tensor):
        return tf.matmul(input_tensor, self.w) + self.b
random_uniform_initializer = tf.random_uniform_initializer()
x = random_uniform_initializer(shape=(4, 3))

layer2 = SimpleLinear(input_dim=x.shape[1], units=4)
y = layer2(x)

print(y)
layer2.trainable_variables
layer2.non_trainable_variables

Demo: Deferring Weight Creation in a Layer

Passing the input shape at instantiation is clunky. By implementing a build() method, the layer can infer the shape of the input the first time it is called.

class SimpleLinear(layers.Layer):

    def __init__(self, units=8, **kwargs):
        super(SimpleLinear, self).__init__(**kwargs)
        self.units = units  # Only units — no input_dim needed!

    def build(self, input_shape):
        # Called automatically before call() — input shape is inferred
        self.w = self.add_weight(
            shape=(input_shape[-1], self.units),  # Last dim = n_features
            initializer='random_normal',
            trainable=True
        )
        self.b = self.add_weight(
            shape=(self.units,),
            initializer='ones',
            trainable=True
        )

    def call(self, input_tensor):
        return tf.matmul(input_tensor, self.w) + self.b
random_uniform_initializer = tf.random_uniform_initializer()
x = random_uniform_initializer(shape=(4, 5))

# No need to specify input_dim — it's inferred from x!
layer3 = SimpleLinear(units=3)
y = layer3(x)

print(y)
layer3.losses

Demo: Accumulating Losses with Custom Layers

Recursive composition: Layers can contain other layers as attributes. The outer layer automatically tracks the weights of all inner layers.

class SimpleLinearBlock(layers.Layer):

    def __init__(self, block_1_units=2, block_2_units=4, block_3_units=8, **kwargs):
        super(SimpleLinearBlock, self).__init__(**kwargs)

        # Three custom SimpleLinear layers as sublayers
        self.linear_1 = SimpleLinear(block_1_units)
        self.linear_2 = SimpleLinear(block_2_units)
        self.linear_3 = SimpleLinear(block_3_units)

    def call(self, inputs):
        x = self.linear_1(inputs)
        x = tf.nn.relu(x)

        x = self.linear_2(x)
        x = tf.nn.relu(x)

        return self.linear_3(x)
random_uniform_initializer = tf.random_uniform_initializer()
x = random_uniform_initializer(shape=(4, 5))

simple_linear_block = SimpleLinearBlock()
y = simple_linear_block(x)

simple_linear_block.weights          # All weights from all 3 sublayers
simple_linear_block.trainable_weights

Custom regularization loss:

class RegularizationLoss(layers.Layer):

    def __init__(self, rate=1e-3, **kwargs):
        super(RegularizationLoss, self).__init__(**kwargs)
        self.rate = rate

    def call(self, input_tensor):
        # add_loss() accumulates the loss — it is collected during training
        self.add_loss(self.rate * tf.reduce_sum(input_tensor))
        return input_tensor
reg_loss_layer = RegularizationLoss()
y = reg_loss_layer(x)
reg_loss_layer.losses   # Shows the accumulated regularization loss

Layer with embedded regularization:

class SimpleLinearRegularized(layers.Layer):

    def __init__(self, units=8, **kwargs):
        super(SimpleLinearRegularized, self).__init__(**kwargs)
        self.units = units
        self.reg = RegularizationLoss(1e-2)

    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='ones', trainable=True
        )

    def call(self, input_tensor):
        output = tf.matmul(input_tensor, self.w) + self.b
        return self.reg(output)

Dense layer with built-in L2 regularization:

class DenseRegularized(layers.Layer):

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

        self.dense = layers.Dense(
            units,
            kernel_regularizer=tf.keras.regularizers.l2(1e-3)
        )
        self.reg = RegularizationLoss(1e-2)

    def call(self, input_tensor):
        return self.reg(self.dense(input_tensor))

Demo: Serializing Layers and the Training Parameter

To serialize and reload custom layers, implement a get_config() method that returns the layer’s configuration.

class SimpleLinear(layers.Layer):

    def __init__(self, units=8, **kwargs):
        super(SimpleLinear, self).__init__(**kwargs)
        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='ones', trainable=True
        )

    def call(self, input_tensor):
        return tf.matmul(input_tensor, self.w) + self.b

    def get_config(self):
        # Required for serialization
        config = super(SimpleLinear, self).get_config()
        config.update({'units': self.units})  # Add custom parameters
        return config
random_uniform_initializer = tf.random_uniform_initializer()
x = random_uniform_initializer(shape=(4, 5))

layer6 = SimpleLinear(units=3)
y = layer6(x)

# Get configuration
config = layer6.get_config()
print(config)  # {name, trainable, dtype, units}

# Reconstruct layer from config
new_layer6 = SimpleLinear.from_config(config)
print(new_layer6.units)  # 3

Layers that behave differently during training vs. prediction:

Some layers (e.g., Dropout, Batch Normalization) behave differently during training versus prediction. You can implement this by checking the training argument in call().

class CustomDropout(layers.Layer):

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

    def call(self, inputs, training=None):
        if training:
            return tf.nn.dropout(inputs, rate=self.rate)
        return inputs  # No dropout during inference
layer7 = CustomDropout(rate=0.4)

# Inference mode — no dropout
y = layer7(x)
print(y)

# Training mode — dropout applied
y = layer7(x, training=True)
print(y)

Demo: Building Custom Models

Using model subclassing, we build a flexible regression model that accepts a configurable number of layers.

Version 1: Fixed architecture:

class CustomRegressionModel(tf.keras.Model):

    def __init__(self, input_shape, units_1=16, units_2=8,
                 activation='relu', initializer='random_normal'):
        super(CustomRegressionModel, self).__init__()

        self.dense1 = layers.Dense(units_1, activation=activation,
                                   kernel_initializer=initializer,
                                   input_shape=[input_shape])
        self.dense2 = layers.Dense(units_2, activation=activation,
                                   kernel_initializer=initializer)
        self.dense3 = layers.Dense(1)  # Regression output

    def call(self, input_tensor):
        x = self.dense1(input_tensor)
        x = self.dense2(x)
        return self.dense3(x)

Version 2: Configurable number of hidden layers:

class CustomRegressionModel(tf.keras.Model):

    def __init__(self, input_shape, layer_units=[8, 4],
                 activation='relu', initializer='random_normal'):
        super(CustomRegressionModel, self).__init__()

        assert len(layer_units) > 0  # At least one hidden layer

        self.input_layer = layers.Dense(layer_units[0],
                                        activation=activation,
                                        kernel_initializer=initializer,
                                        input_shape=[input_shape])

        self.hidden_layers = []
        for i in range(1, len(layer_units)):
            self.hidden_layers.append(
                layers.Dense(layer_units[i],
                             activation=activation,
                             kernel_initializer=initializer)
            )

        self.output_layer = layers.Dense(1)  # Single output for regression

    def call(self, input_tensor):
        x = self.input_layer(input_tensor)
        for layer in self.hidden_layers:
            x = layer(x)
        return self.output_layer(x)
random_uniform_initializer = tf.random_uniform_initializer()
x = random_uniform_initializer(shape=(4, 8))

custom_reg_model = CustomRegressionModel(x.shape[-1], [8, 16, 32], 'sigmoid')
custom_reg_model(x)   # Must call with data before summary works
custom_reg_model.summary()

Demo: Building and Training a Regression Model Using Custom Layers

Custom Dense Layer (MyDense):

This layer implements the same transformation as layers.Dense but is entirely user-defined.

import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import r2_score

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, activations

import tensorflow_docs as tfdocs
import tensorflow_docs.modeling
import tensorflow_docs.plots
# Load preprocessed insurance data
data = pd.read_csv('datasets/insurance_processed.csv')
features = data.drop('charges', axis=1)
target = data[['charges']]

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(
    features, target, test_size=0.2, random_state=1
)

x_train.shape, x_test.shape
class MyDense(layers.Layer):

    def __init__(self, units, activation=None, **kwargs):
        super(MyDense, self).__init__(**kwargs)
        self.units = units
        self.activation = activations.get(activation)  # Look up by name

    def build(self, input_shape):
        input_dim = input_shape[-1]
        # No bias in this simplified dense layer
        self.kernel = self.add_weight(shape=(input_dim, self.units))

    def call(self, inputs):
        output = tf.matmul(inputs, self.kernel)
        if self.activation is not None:
            output = self.activation(output)
        return output

    def compute_output_shape(self, input_shape):
        output_shape = list(input_shape)
        output_shape[-1] = self.units
        return tuple(output_shape)

    def get_config(self):
        config = super(MyDense, self).get_config()
        current_config = {
            'units': self.units,
            'activation': activations.serialize(self.activation)
        }
        config.update(current_config)
        return config

Building a Sequential model with custom MyDense layers:

def build_model():
    model = tf.keras.Sequential([
        MyDense(64, activation='elu', input_shape=[x_train.shape[1]]),
        MyDense(32, activation='elu'),
        layers.Dropout(0.2),
        MyDense(16, activation='elu'),
        MyDense(1)  # Regression output
    ])

    optimizer = tf.keras.optimizers.RMSprop(0.001)
    model.compile(loss='mse', optimizer=optimizer, metrics=['mae', 'mse'])
    return model
sequential_model = build_model()
sequential_model.summary()
sequential_model.layers

Train the model:

n_epochs = 1000

training_hist = sequential_model.fit(
    x_train, y_train,
    epochs=n_epochs,
    validation_split=0.2,
    verbose=False,
    callbacks=[tfdocs.modeling.EpochDots()]
)

Evaluate:

y_pred = sequential_model.predict(x_test).flatten()

plt.figure(figsize=(12, 8))
plt.scatter(y_test, y_pred, s=200, c='darkblue')
plt.xlabel('Actual charges values')
plt.ylabel('Predicted charges values')
plt.show()

r2_score(y_test, y_pred)

Demo: Building and Training a Custom Model with Custom Layers

Custom regression model using MyDense layers:

class CustomRegressionModel(tf.keras.Model):

    def __init__(self, input_shape, layer_units=[8, 4], activation='relu'):
        super(CustomRegressionModel, self).__init__()

        assert len(layer_units) > 0

        self.input_layer = MyDense(layer_units[0],
                                   activation=activation,
                                   input_shape=[input_shape])

        self.hidden_layers = []
        for i in range(1, len(layer_units)):
            self.hidden_layers.append(
                MyDense(layer_units[i], activation=activation)
            )

        self.output_layer = MyDense(1)  # Single output for regression

    def call(self, input_tensor):
        x = self.input_layer(input_tensor)
        for layer in self.hidden_layers:
            x = layer(x)
        return self.output_layer(x)
# Instantiate with 3 hidden layers: [64, 32, 16] with elu activation
custom_model = CustomRegressionModel(x_train.shape[1], [64, 32, 16], 'elu')

# Must make one forward pass before calling summary
custom_model(tf.zeros([5, x_train.shape[1]]))

custom_model.layers
custom_model.summary()

Compile and train:

custom_model.compile(
    loss='mse',
    optimizer=tf.keras.optimizers.RMSprop(0.001),
    metrics=['mse']
)

n_epochs = 1000

training_hist = custom_model.fit(
    x_train.values, y_train.values,
    epochs=n_epochs,
    validation_split=0.2,
    verbose=False,
    callbacks=[tfdocs.modeling.EpochDots()]
)

Plot training loss:

plotter = tfdocs.plots.HistoryPlotter(smoothing_std=2)

plt.figure(figsize=(12, 8))
plotter.plot({'Basic': training_hist}, metric="mse")
plt.ylabel('MSE[Charges^2]')
plt.show()

Evaluate and predict:

y_pred = custom_model.predict(x_test.values).flatten()

plt.figure(figsize=(12, 8))
plt.scatter(y_test, y_pred, s=200, c='darkblue')
plt.xlabel('Actual charges values')
plt.ylabel('Predicted charges values')
plt.show()

r2_score(y_test, y_pred)

Save and reload weights:

# Save custom model weights (HDF5 format)
custom_model.save_weights('my_models/custom_model.h5', save_format="h5")

# Reload into a new model instance
new_model = CustomRegressionModel(x_train.shape[1], [64, 32, 16], 'elu')
new_model(tf.zeros([5, x_train.shape[1]]))  # Build model first

new_model.compile(
    loss='mse',
    optimizer=tf.keras.optimizers.RMSprop(0.001),
    metrics=['mse']
)

new_model.load_weights('my_models/custom_model.h5')

# Evaluate reloaded model
y_pred_new = new_model.predict(x_test.values)
r2_score(y_test, y_pred_new)
flowchart TD
    Input["Input Layer\nMyDense(64, elu)"] --> H1["Hidden Layer 1\nMyDense(32, elu)"]
    H1 --> H2["Hidden Layer 2\nMyDense(16, elu)"]
    H2 --> Out["Output Layer\nMyDense(1)"]
    style Input fill:#4a9eff,color:#fff
    style Out fill:#ff6b6b,color:#fff

Summary

This course covered the full spectrum of model-building approaches in Keras with TensorFlow 2.0:

Key Takeaways by Module

ModuleTopicKey Concepts
2Keras FundamentalsSequential API, Functional API, Model saving/loading
3Regression & ClassificationDense layers, Callbacks (EarlyStopping, Checkpoint, TensorBoard), Functional API
4Image ClassificationCNN, Conv2D, MaxPooling2D, Dropout, Data Augmentation, ImageDataGenerator
5Unsupervised LearningAutoencoders, Encoder-Decoder, Conv2DTranspose, Latent space
6Custom Layers & ModelsLayer subclassing, build(), call(), get_config(), Model subclassing

Quick Reference: Model Building Strategies

# 1. Sequential API — simple linear stack
model = tf.keras.Sequential([
    layers.Dense(32, activation='relu', input_shape=[n]),
    layers.Dense(1)
])

# 2. Functional API — complex topologies
inputs = tf.keras.Input(shape=(n,))
x = layers.Dense(16, activation='relu')(inputs)
outputs = layers.Dense(1)(x)
model = tf.keras.Model(inputs=inputs, outputs=outputs)

# 3. Model Subclassing — full control
class MyModel(tf.keras.Model):
    def __init__(self):
        super().__init__()
        self.dense = layers.Dense(1)
    def call(self, x):
        return self.dense(x)

# 4. Custom Layer
class MyLayer(layers.Layer):
    def build(self, input_shape):
        self.w = self.add_weight(shape=(input_shape[-1], self.units))
    def call(self, x):
        return tf.matmul(x, self.w)

Training Workflow

flowchart TD
    Data["Load & Preprocess Data\n(pandas, sklearn, ImageDataGenerator)"] --> Split["Train/Validation/Test Split"]
    Split --> Build["Build Model\n(Sequential / Functional / Subclassing)"]
    Build --> Compile["model.compile()\n(optimizer, loss, metrics)"]
    Compile --> Fit["model.fit()\n(callbacks: EarlyStopping, Checkpoint, TensorBoard)"]
    Fit --> Eval["model.evaluate()\n(test data)"]
    Eval --> Predict["model.predict()"]
    Predict --> Save["model.save() / save_weights()"]
    Save --> Reload["load_model() / load_weights()"]

Loss Functions Reference

TaskLoss FunctionNotes
Regressionmse (Mean Squared Error)Also tracked: mae
Binary ClassificationBinaryCrossentropySigmoid output, threshold = 0.5
Multi-class ClassificationCategoricalCrossentropySoftmax output
Autoencoder (dense)mean_squared_errorReconstruction quality
Autoencoder (CNN)BinaryCrossentropyPixel-level binary cross-entropy

Activation Functions Reference

ActivationFormulaUse Case
relu$\max(0, x)$Default for hidden layers
elu$x$ if $x>0$; $e^x - 1$ if $x \le 0$Faster convergence, mitigates saturation
sigmoid$\frac{1}{1+e^{-x}}$Binary classification output
softmax$\frac{e^{x_i}}{\sum_j e^{x_j}}$Multi-class classification output

Callbacks Reference

CallbackPurposeKey Parameters
EarlyStoppingStop training when no improvementmonitor, patience
ModelCheckpointSave model at intervalsfilepath, save_freq, save_weights_only
TensorBoardVisualize training metricslog_dir, histogram_freq
tfdocs.modeling.EpochDotsPrint progress dots

Search Terms

machine · workflow · keras · tensorflow · 2.0 · deep · neural · networks · data · science · models · layers · custom · model · reference · unsupervised · callbacks · convolutional · dataset · images · loading · api · architecture · autoencoder

Interested in this course?

Contact us to book it or get a custom training plan for your team.