Skip to content

17. PyTorch Autograd, Dataset and DataLoader

Understand how PyTorch automatically computes gradients and how Dataset and DataLoader provide the data pipeline required for scalable Deep Learning training. This chapter connects tensors, automatic differentiation, batching, shuffling, multiprocessing, and model training into a complete PyTorch data-to-gradient workflow.


๐ŸŽฏ Learning Objectives

After completing this chapter, you will be able to:

  • Understand PyTorch automatic differentiation
  • Explain how computational graphs are created
  • Understand requires_grad
  • Use backward() to calculate gradients
  • Understand gradient accumulation
  • Clear gradients correctly
  • Use detach() and torch.no_grad()
  • Understand leaf and non-leaf tensors
  • Inspect gradients during training
  • Understand Jacobians and vector-Jacobian products at a high level
  • Create custom PyTorch Dataset classes
  • Understand __len__() and __getitem__()
  • Understand map-style datasets
  • Understand iterable-style datasets
  • Build datasets from tensors
  • Use TensorDataset
  • Use DataLoader
  • Understand batching
  • Understand shuffling
  • Understand batch_size
  • Understand drop_last
  • Understand num_workers
  • Understand pin_memory
  • Understand persistent_workers
  • Understand custom collate_fn
  • Handle variable-length data
  • Understand worker processes
  • Build efficient training input pipelines
  • Move batches efficiently to GPU
  • Combine Dataset, DataLoader, model, loss, autograd, and optimizer
  • Design production-oriented PyTorch data pipelines

๐Ÿ“– Overview

A Deep Learning model cannot learn without data.

In PyTorch, the training pipeline typically consists of:

Raw Data
   โ†“
Dataset
   โ†“
DataLoader
   โ†“
Batch
   โ†“
Model
   โ†“
Prediction
   โ†“
Loss
   โ†“
Autograd
   โ†“
Gradients
   โ†“
Optimizer
   โ†“
Updated Parameters

The two major responsibilities are:

Dataset
    โ†“
Defines how individual samples are accessed

DataLoader
    โ†“
Defines how samples are organized and delivered to training

At the same time:

Autograd
    โ†“
Computes gradients required to optimize the model

Together, these components form the core PyTorch training infrastructure.


๐Ÿง  Complete PyTorch Training Architecture

flowchart TD

    DATA["Raw Dataset"]

    DATASET["PyTorch Dataset"]

    LOADER["DataLoader"]

    BATCH["Mini-Batch"]

    DEVICE["CPU / GPU"]

    MODEL["Neural Network"]

    PRED["Predictions"]

    LOSS["Loss"]

    AUTOGRAD["Autograd"]

    GRAD["Gradients"]

    OPT["Optimizer"]

    UPDATE["Updated Parameters"]

    DATA --> DATASET
    DATASET --> LOADER
    LOADER --> BATCH
    BATCH --> DEVICE
    DEVICE --> MODEL
    MODEL --> PRED
    PRED --> LOSS
    LOSS --> AUTOGRAD
    AUTOGRAD --> GRAD
    GRAD --> OPT
    OPT --> UPDATE
    UPDATE --> MODEL

๐Ÿ”ฌ Part I โ€” PyTorch Autograd

๐Ÿง  What Is Automatic Differentiation?

Training a neural network requires calculating:

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

where:

L = Loss
ฮธ = Model Parameters

These gradients tell the optimizer how model parameters should change.

PyTorch provides:

torch.autograd

to automatically calculate these derivatives.


๐Ÿงฎ Gradient-Based Learning

The basic parameter update is:

[ \theta_{t+1} = \theta_t - \eta \nabla_\theta L ]

Where:

ฮธ          = Model Parameters
ฮท          = Learning Rate
L          = Loss
โˆ‡ฮธL        = Gradient of Loss

Autograd provides:

โˆ‡ฮธL

The optimizer performs the parameter update.


๐Ÿง  Computational Graph

PyTorch builds a dynamic computational graph while performing operations on tensors that require gradients.

For:

x = torch.tensor(
    3.0,
    requires_grad=True
)

y = x ** 2

z = y + 5

the conceptual graph is:

flowchart LR

    X["x = 3"]

    SQUARE["xยฒ"]

    Y["y"]

    ADD["y + 5"]

    Z["z"]

    X --> SQUARE
    SQUARE --> Y
    Y --> ADD
    ADD --> Z

When:

z.backward()

is executed, PyTorch traverses the graph backward to calculate gradients.


๐Ÿงช Basic Autograd Example

import torch


x = torch.tensor(
    3.0,
    requires_grad=True
)

y = x ** 2

y.backward()

print(
    x.grad
)

Output:

tensor(6.)

Because:

[ y=x^2 ]

therefore:

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

and:

[ 2(3)=6 ]


๐Ÿง  requires_grad

A tensor can request gradient tracking using:

requires_grad=True

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

Operations involving x are then tracked by autograd.


๐Ÿ” Checking requires_grad

print(
    x.requires_grad
)

Output:

True

For a tensor without gradient tracking:

x = torch.tensor(
    2.0
)

print(
    x.requires_grad
)

Output:

False

๐Ÿง  When Does Autograd Track Operations?

Conceptually:

Tensor requires_grad=True
           โ”‚
           โ–ผ
Differentiable Operation
           โ”‚
           โ–ผ
Computational Graph
           โ”‚
           โ–ผ
backward()
           โ”‚
           โ–ผ
Gradient

If gradient tracking is disabled, the operations are not recorded for backward differentiation.


๐Ÿงฎ Multiple Operations

Consider:

x = torch.tensor(
    2.0,
    requires_grad=True
)

y = x ** 2

z = 3 * y

output = z + 4

Mathematically:

[ output=3x^2+4 ]

The derivative is:

[ \frac{d(output)}{dx}=6x ]

At:

[ x=2 ]

the gradient is:

12

๐Ÿง  Backward Pass

Calling:

output.backward()

causes PyTorch to calculate the derivative of the output with respect to the tracked leaf tensor.

flowchart RL

    OUTPUT["Output"]

    OP3["Operation 3"]

    OP2["Operation 2"]

    OP1["Operation 1"]

    X["Input x"]

    OUTPUT --> OP3
    OP3 --> OP2
    OP2 --> OP1
    OP1 --> X

This is the computational foundation of backpropagation.


๐Ÿง  grad_fn

Non-leaf tensors participating in autograd often have a:

grad_fn

attribute.

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

y = x ** 2

print(
    y.grad_fn
)

The exact representation may vary, but it indicates that PyTorch has recorded the operation that produced y.


๐Ÿง  Leaf Tensors

A leaf tensor is generally a tensor created directly by the user or one that is not the result of an autograd-tracked operation.

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

x is a leaf tensor.

Then:

y = x * 3

creates a derived tensor.

Conceptually:

x
โ”‚
โ”œโ”€โ”€ Leaf Tensor
โ”‚
โ””โ”€โ”€ y
    โ””โ”€โ”€ Derived Tensor

๐Ÿ” Checking Leaf Status

print(
    x.is_leaf
)

print(
    y.is_leaf
)

Typically:

x โ†’ True
y โ†’ False

Understanding leaf tensors becomes important when inspecting gradients.


๐Ÿง  Where Are Gradients Stored?

For a leaf tensor:

x.grad

contains its accumulated gradient after:

backward()

Example:

x = torch.tensor(
    3.0,
    requires_grad=True
)

y = x ** 2

y.backward()

print(
    x.grad
)

โš  Non-Leaf Gradients

By default, PyTorch does not retain .grad for every non-leaf tensor.

If you need to inspect the gradient of a non-leaf tensor, use:

y.retain_grad()

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

y = x ** 2

y.retain_grad()

z = y * 3

z.backward()

print(
    y.grad
)

๐Ÿง  Gradient Accumulation

One of the most important PyTorch concepts is:

Gradients accumulate by default.

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

y = x ** 2

y.backward()

print(
    x.grad
)

If another backward pass is performed:

y = x ** 2

y.backward()

print(
    x.grad
)

the gradient is accumulated rather than automatically replaced.


๐Ÿงน Clearing Gradients

This is why PyTorch training loops normally contain:

optimizer.zero_grad()

The standard workflow is:

optimizer.zero_grad()

prediction = model(
    x
)

loss = loss_fn(
    prediction,
    y
)

loss.backward()

optimizer.step()

๐Ÿ”„ Gradient Lifecycle

flowchart LR

    PARAM["Model Parameters"]

    ZERO["zero_grad()"]

    FORWARD["Forward Pass"]

    LOSS["Loss"]

    BACKWARD["backward()"]

    GRAD["Gradients"]

    STEP["optimizer.step()"]

    PARAM --> ZERO
    ZERO --> FORWARD
    FORWARD --> LOSS
    LOSS --> BACKWARD
    BACKWARD --> GRAD
    GRAD --> STEP
    STEP --> PARAM

๐Ÿง  Why Does PyTorch Accumulate Gradients?

Gradient accumulation provides flexibility for:

  • Multiple backward passes
  • Gradient accumulation training
  • Complex optimization algorithms
  • Multiple losses
  • Multi-stage optimization

However, for standard mini-batch training, gradients usually need to be cleared before the next update.


๐Ÿงช Complete Gradient Example

import torch


x = torch.tensor(
    2.0,
    requires_grad=True
)


y = x ** 3

y.backward()


print(
    "Value:",
    x.item()
)

print(
    "Gradient:",
    x.grad.item()
)

Since:

[ y=x^3 ]

then:

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

At:

[ x=2 ]

the gradient is:

12

๐Ÿง  Vector-Valued Outputs

backward() is simplest when the output is scalar.

Example:

x = torch.tensor(
    [1.0, 2.0, 3.0],
    requires_grad=True
)

y = x ** 2

loss = y.sum()

loss.backward()

print(
    x.grad
)

Output:

tensor([2., 4., 6.])

๐Ÿง  Why Reduce the Loss to a Scalar?

Training losses are typically reduced to a scalar because the optimizer needs a single objective.

For example:

loss = loss_fn(
    predictions,
    targets
)

typically returns a scalar when using the default reduction.

Conceptually:

Batch Predictions
       โ†“
Per-Sample Loss
       โ†“
Mean / Sum
       โ†“
Scalar Loss
       โ†“
backward()

๐Ÿงฎ Vector-Jacobian Product

For vector-valued outputs, PyTorch's backward mechanism computes a vector-Jacobian product rather than automatically materializing the complete Jacobian.

Conceptually:

[ v^T J ]

where:

v = upstream gradient
J = Jacobian

For ordinary neural-network training, this complexity is generally hidden because the final loss is usually scalar.


๐Ÿง  Supplying an Upstream Gradient

For a non-scalar output:

x = torch.tensor(
    [1.0, 2.0, 3.0],
    requires_grad=True
)

y = x ** 2

y.backward(
    torch.ones_like(y)
)

print(
    x.grad
)

The supplied tensor represents the upstream gradient.


๐Ÿง  Detaching from the Graph

Use:

detach()

when you want a tensor disconnected from its autograd history.

Example:

x = torch.tensor(
    2.0,
    requires_grad=True
)

y = x ** 2

z = y.detach()

Now:

z.requires_grad

is:

False

๐Ÿง  detach() Mental Model

flowchart LR

    X["Input"]

    GRAPH["Autograd Graph"]

    Y["Computed Tensor"]

    DETACH["detach()"]

    Z["Detached Tensor"]

    X --> GRAPH
    GRAPH --> Y
    Y --> DETACH
    DETACH --> Z

The detached tensor shares storage with the original tensor in typical cases, but does not track the original computation history.


๐Ÿง  torch.no_grad()

For inference:

with torch.no_grad():

    predictions = model(
        x
    )

This disables gradient tracking for operations within the context.


๐Ÿง  torch.inference_mode()

PyTorch also provides:

with torch.inference_mode():

    predictions = model(
        x
    )

This is intended for inference workloads and can provide additional performance benefits in appropriate situations.


๐Ÿง  no_grad() vs detach()

torch.no_grad() detach()
Context manager Tensor operation
Disables gradient tracking for operations Disconnects a tensor from its graph
Common during inference Common when separating tensors from training graphs
Applies to operations in scope Applies to returned tensor

โš  Common Autograd Mistakes

Avoid:

  • Forgetting requires_grad when manually testing gradients
  • Forgetting optimizer.zero_grad()
  • Calling backward() repeatedly without understanding accumulation
  • Converting tensors requiring gradients directly to NumPy
  • Accidentally detaching tensors required for training
  • Using NumPy operations inside a differentiable PyTorch computation
  • Keeping unnecessary computation graphs in memory
  • Performing inference with unnecessary gradient tracking
  • Confusing leaf and non-leaf tensors

๐Ÿ”ฌ Part II โ€” PyTorch Dataset

๐Ÿง  Why Do We Need Dataset?

A real-world dataset can contain:

Millions of Images
Large Text Corpora
Audio Files
Video
Tabular Records
Sensor Data
Documents

The complete dataset often cannot be loaded into GPU memory.

A Dataset provides a controlled interface for accessing samples.


๐Ÿง  Dataset Responsibility

A Dataset generally answers:

"Given an index, how do I obtain the corresponding training sample?"

For example:

index = 42
      โ†“
Dataset
      โ†“
Load Sample 42
      โ†“
Return Input + Target

๐Ÿงฑ PyTorch Dataset

PyTorch provides:

torch.utils.data.Dataset

A common custom Dataset implements:

__len__()
__getitem__()

๐Ÿงช Basic Custom Dataset

from torch.utils.data import Dataset


class MyDataset(
    Dataset
):

    def __init__(
        self,
        features,
        labels
    ):

        self.features = features
        self.labels = labels

    def __len__(
        self
    ):

        return len(
            self.features
        )

    def __getitem__(
        self,
        index
    ):

        return (
            self.features[index],
            self.labels[index]
        )

๐Ÿง  __len__()

__len__() tells PyTorch how many samples are available.

Example:

def __len__(
    self
):

    return len(
        self.features
    )

If the dataset contains:

10,000 samples

then:

len(dataset)

returns:

10000

๐Ÿง  __getitem__()

__getitem__() retrieves one sample.

Example:

def __getitem__(
    self,
    index
):

    return (
        self.features[index],
        self.labels[index]
    )

Then:

x, y = dataset[10]

retrieves sample 10.


๐Ÿง  Dataset Architecture

flowchart LR

    REQUEST["Index"]

    DATASET["Dataset"]

    LOAD["Load Sample"]

    TRANSFORM["Transform"]

    SAMPLE["Input + Target"]

    REQUEST --> DATASET
    DATASET --> LOAD
    LOAD --> TRANSFORM
    TRANSFORM --> SAMPLE

๐Ÿงช Dataset Example

import torch
from torch.utils.data import Dataset


class NumberDataset(
    Dataset
):

    def __init__(
        self,
        size
    ):

        self.x = torch.arange(
            size,
            dtype=torch.float32
        )

        self.y = (
            self.x * 2
        )

    def __len__(
        self
    ):

        return len(
            self.x
        )

    def __getitem__(
        self,
        index
    ):

        return (
            self.x[index],
            self.y[index]
        )

Usage:

dataset = NumberDataset(
    100
)

print(
    len(dataset)
)

print(
    dataset[0]
)

๐Ÿง  Map-Style Dataset

The custom Dataset shown above is a map-style dataset.

It provides:

Index โ†’ Sample

through:

__getitem__()

and:

__len__()

This is the most common Dataset style for ordinary supervised learning.


๐Ÿ”„ Map-Style Dataset

flowchart LR

    I0["Index 0"] --> S0["Sample 0"]

    I1["Index 1"] --> S1["Sample 1"]

    I2["Index 2"] --> S2["Sample 2"]

    IN["Index N"] --> SN["Sample N"]

๐Ÿง  Iterable-Style Dataset

PyTorch also supports iterable-style datasets.

These implement:

__iter__()

rather than random indexed access.

Useful examples include:

  • Streaming datasets
  • Large sequential datasets
  • Data generated dynamically
  • Data arriving from external streams
  • Datasets where random access is inefficient

๐Ÿงช Iterable Dataset

from torch.utils.data import IterableDataset


class NumberStream(
    IterableDataset
):

    def __init__(
        self,
        start,
        end
    ):

        self.start = start
        self.end = end

    def __iter__(
        self
    ):

        for value in range(
            self.start,
            self.end
        ):

            yield value

Usage:

dataset = NumberStream(
    0,
    100
)

for value in dataset:

    print(
        value
    )

๐Ÿง  Map-Style vs Iterable-Style

Map-Style Iterable-Style
Uses __getitem__() Uses __iter__()
Usually supports indexing Sequential iteration
Usually has __len__() Length may not be known
Good for ordinary datasets Good for streams
Supports index-based sampling Natural for streaming
Common in supervised learning Useful for large / dynamic data

๐Ÿง  Dataset Transforms

Datasets often require preprocessing:

Raw Data
   โ†“
Decode
   โ†“
Resize
   โ†“
Normalize
   โ†“
Tensor

For images:

Image
 โ†“
Resize
 โ†“
Crop
 โ†“
Normalize
 โ†“
Tensor

For text:

Raw Text
 โ†“
Tokenization
 โ†“
Numerical IDs
 โ†“
Padding
 โ†“
Tensor

๐Ÿง  Dataset Responsibilities

A well-designed Dataset can handle:

Data Access
+
Basic Sample-Level Transformation
+
Label Retrieval

It should generally not become a giant training framework.

Keep responsibilities separated.


๐Ÿง  TensorDataset

For data that already exists as tensors, PyTorch provides:

TensorDataset

Example:

from torch.utils.data import TensorDataset


x = torch.randn(
    1000,
    10
)

y = torch.randint(
    0,
    2,
    (1000,)
)

dataset = TensorDataset(
    x,
    y
)

Now:

x_sample, y_sample = dataset[0]

๐Ÿง  Multiple Inputs

TensorDataset can also represent multiple tensors.

dataset = TensorDataset(
    features,
    labels,
    metadata
)

Each sample returns:

features
labels
metadata

provided the tensors have compatible first dimensions.


๐Ÿ”ฌ Part III โ€” DataLoader

๐Ÿง  Why DataLoader?

A Dataset provides individual samples.

A DataLoader provides batches and iteration behavior.

Dataset
    โ†“
Individual Samples

DataLoader
    โ†“
Mini-Batches

๐Ÿง  DataLoader Responsibilities

DataLoader can provide:

  • Batching
  • Shuffling
  • Sampling
  • Multi-process loading
  • Memory pinning
  • Custom collation
  • Batch iteration

๐Ÿงช Basic DataLoader

from torch.utils.data import DataLoader


loader = DataLoader(
    dataset,
    batch_size=32,
    shuffle=True
)

Now:

for x_batch, y_batch in loader:

    print(
        x_batch.shape
    )

๐Ÿง  Dataset โ†’ DataLoader

flowchart LR

    DATASET["Dataset"]

    S1["Sample 1"]
    S2["Sample 2"]
    S3["Sample 3"]
    SN["Sample N"]

    BATCH["Mini-Batch"]

    DATASET --> S1
    DATASET --> S2
    DATASET --> S3
    DATASET --> SN

    S1 --> BATCH
    S2 --> BATCH
    S3 --> BATCH
    SN --> BATCH

The DataLoader coordinates how individual samples are assembled into batches.


๐Ÿ“ฆ Batch Size

Consider:

dataset_size = 1000

and:

batch_size = 32

The DataLoader produces approximately:

32 samples
32 samples
32 samples
...

The final batch may contain fewer samples unless:

drop_last=True

is used.


๐Ÿงฎ Number of Batches

For a dataset of size:

[ N ]

and batch size:

[ B ]

the number of batches with the final partial batch retained is:

[ \left\lceil\frac{N}{B}\right\rceil ]

For:

N = 1000
B = 32

the DataLoader produces:

32 batches

with the final batch containing fewer samples.


๐Ÿง  drop_last

loader = DataLoader(
    dataset,
    batch_size=32,
    drop_last=True
)

This discards the final incomplete batch.

For:

1000 samples
32 batch size

only complete batches are retained.

This can be useful when consistent batch shapes are desirable.


๐Ÿง  When Is drop_last=True Useful?

Potential use cases include:

  • Batch Normalization behavior
  • Distributed training
  • Models requiring fixed batch shapes
  • Certain contrastive learning approaches
  • Avoiding unusually small final batches

It should not be enabled blindly because it discards data.


๐Ÿ”€ Shuffling

Training datasets are commonly shuffled.

loader = DataLoader(
    dataset,
    batch_size=32,
    shuffle=True
)

Conceptually:

Epoch 1:
1 2 3 4 5 6 ...

Epoch 2:
7 3 1 9 2 5 ...

The exact ordering changes.


๐Ÿง  Why Shuffle Training Data?

Shuffling helps reduce undesirable ordering effects.

For example, if a dataset is ordered:

Class A
Class A
Class A
...
Class B
Class B
Class B
...

training without shuffling can expose the model to long runs of one class.

Randomized batching generally provides a better mixture of training examples.


โš  Should Validation Data Be Shuffled?

Usually:

Training โ†’ shuffle=True
Validation โ†’ shuffle=False
Test โ†’ shuffle=False

Shuffling validation or test data is generally unnecessary unless there is a specific reason to do so.


๐Ÿง  DataLoader Sampling

DataLoader can use samplers to control which indices are selected.

Examples include:

SequentialSampler
RandomSampler
WeightedRandomSampler
DistributedSampler

This is particularly useful for:

  • Imbalanced datasets
  • Distributed training
  • Specialized sampling strategies

โš–๏ธ Weighted Sampling

For imbalanced classification:

Class A โ†’ 95%
Class B โ†’ 5%

a weighted sampling strategy can increase the frequency with which minority examples are selected.

Example:

from torch.utils.data import WeightedRandomSampler

Conceptually:

Rare Class
   โ†“
Higher Sampling Weight
   โ†“
Selected More Frequently

๐Ÿง  DataLoader Worker Processes

Data loading can become a bottleneck when:

GPU is fast
+
Data preprocessing is slow

PyTorch supports:

num_workers

Example:

loader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    num_workers=4
)

Multiple worker processes can prepare batches while the model is computing.


๐Ÿ— Parallel Data Loading

flowchart LR

    DATA["Dataset"]

    W1["Worker 1"]
    W2["Worker 2"]
    W3["Worker 3"]
    W4["Worker 4"]

    QUEUE["Batch Queue"]

    GPU["GPU Training"]

    DATA --> W1
    DATA --> W2
    DATA --> W3
    DATA --> W4

    W1 --> QUEUE
    W2 --> QUEUE
    W3 --> QUEUE
    W4 --> QUEUE

    QUEUE --> GPU

The objective is to overlap data preparation with model computation.


๐Ÿง  num_workers

Example:

DataLoader(
    dataset,
    batch_size=64,
    num_workers=4
)

means multiple worker processes can be used for data loading.

The optimal value depends on:

CPU Cores
Dataset Complexity
Storage Speed
Preprocessing Cost
Batch Size
GPU Speed
Operating System
Memory

There is no universal optimal number.


โš  More Workers โ‰  Always Faster

Increasing workers can increase:

CPU Usage
Memory Usage
Process Overhead
I/O Contention

Therefore:

Benchmark the pipeline rather than blindly maximizing num_workers.


๐Ÿง  pin_memory

When using CUDA, DataLoader can optionally use pinned host memory:

loader = DataLoader(
    dataset,
    batch_size=64,
    pin_memory=True
)

Pinned memory can improve host-to-GPU transfer efficiency in appropriate workloads.


๐Ÿš€ Pinned Memory + GPU Transfer

A common pattern is:

x_batch = x_batch.to(
    device,
    non_blocking=True
)

when using pinned memory appropriately.

Conceptually:

CPU Pinned Memory
       โ†“
Efficient Transfer
       โ†“
GPU Memory

๐Ÿง  persistent_workers

When using multiple workers, workers may otherwise be shut down and recreated between epochs.

For repeated training epochs, you can consider:

DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    persistent_workers=True
)

This keeps worker processes alive between epochs.

Use it when the workload benefits from avoiding worker startup overhead.


๐Ÿง  DataLoader Configuration

A production-oriented DataLoader may look like:

loader = DataLoader(

    dataset,

    batch_size=64,

    shuffle=True,

    num_workers=4,

    pin_memory=True,

    persistent_workers=True,

    drop_last=True
)

Do not copy this configuration blindly.

Each option should be chosen based on the workload.


๐Ÿง  collate_fn

DataLoader needs to combine individual samples into batches.

The default behavior works for many fixed-size tensors.

But consider variable-length data:

Sample 1 โ†’ length 10
Sample 2 โ†’ length 20
Sample 3 โ†’ length 15

These cannot always be directly stacked.

A custom:

collate_fn

can solve this.


๐Ÿง  Default Collation

Conceptually:

Sample 1 โ”€โ”
Sample 2 โ”€โ”ผโ”€โ”€> Collate โ”€โ”€> Batch Tensor
Sample 3 โ”€โ”˜

For fixed-size tensors:

[3]
[3]
[3]
 โ†“
[batch, 3]

๐Ÿงช Custom collate_fn

def custom_collate(
    batch
):

    inputs = [
        item[0]
        for item in batch
    ]

    labels = [
        item[1]
        for item in batch
    ]

    return (
        inputs,
        labels
    )

Use:

loader = DataLoader(
    dataset,
    batch_size=32,
    collate_fn=custom_collate
)

๐Ÿง  Variable-Length Sequences

For NLP or sequence data:

"I like AI"
        โ†“
3 tokens

"I like Deep Learning"
        โ†“
4 tokens

A batch may require:

Padding
+
Attention Mask

A custom collation function can prepare the batch.

flowchart TD

    S1["Sequence 1<br>Length 3"]

    S2["Sequence 2<br>Length 5"]

    S3["Sequence 3<br>Length 4"]

    COLLATE["Custom Collate"]

    PAD["Padding"]

    BATCH["Padded Batch"]

    S1 --> COLLATE
    S2 --> COLLATE
    S3 --> COLLATE

    COLLATE --> PAD
    PAD --> BATCH

๐Ÿง  Dataset vs DataLoader

Dataset DataLoader
Defines sample access Defines iteration
Provides individual samples Provides batches
Implements data access logic Handles batching
Can apply sample transformations Can shuffle
Usually implements __getitem__() Supports multiple workers
Usually implements __len__() Supports custom collation

Simple rule:

Dataset defines what the data is; DataLoader defines how the data is delivered.


๐Ÿง  Dataset + DataLoader + GPU

A typical training pipeline is:

flowchart LR

    STORAGE["Storage"]

    DATASET["Dataset"]

    LOADER["DataLoader"]

    CPU["CPU Batch"]

    GPU["GPU Batch"]

    MODEL["Model"]

    STORAGE --> DATASET
    DATASET --> LOADER
    LOADER --> CPU
    CPU --> GPU
    GPU --> MODEL

๐Ÿงช Complete Dataset + DataLoader Example

import torch

from torch.utils.data import (
    Dataset,
    DataLoader
)


class RegressionDataset(
    Dataset
):

    def __init__(
        self,
        size
    ):

        self.x = torch.randn(
            size,
            10
        )

        self.y = (
            self.x.sum(
                dim=1,
                keepdim=True
            )
        )

    def __len__(
        self
    ):

        return len(
            self.x
        )

    def __getitem__(
        self,
        index
    ):

        return (
            self.x[index],
            self.y[index]
        )


dataset = RegressionDataset(
    10000
)


loader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True
)


for x_batch, y_batch in loader:

    print(
        x_batch.shape,
        y_batch.shape
    )

    break

Expected shapes:

x_batch โ†’ [64, 10]
y_batch โ†’ [64, 1]

๐Ÿง  Complete Training Pipeline

for epoch in range(
    epochs
):

    model.train()

    for x_batch, y_batch in train_loader:

        x_batch = x_batch.to(
            device
        )

        y_batch = y_batch.to(
            device
        )

        optimizer.zero_grad()

        predictions = model(
            x_batch
        )

        loss = loss_fn(
            predictions,
            y_batch
        )

        loss.backward()

        optimizer.step()

๐Ÿง  Production Data Pipeline

A production-oriented PyTorch pipeline can be viewed as:

Object Storage / Database / Files
              โ†“
          Dataset
              โ†“
      Transform / Decode
              โ†“
          DataLoader
              โ†“
      Worker Processes
              โ†“
       Batch Collation
              โ†“
       Pinned CPU Memory
              โ†“
        GPU Transfer
              โ†“
        Model Training

๐Ÿข Enterprise Data Pipeline Architecture

flowchart TD

    STORAGE["Enterprise Storage"]

    DATASET["PyTorch Dataset"]

    TRANSFORM["Preprocessing / Augmentation"]

    WORKERS["DataLoader Workers"]

    COLLATE["Batch Collation"]

    PIN["Pinned Memory"]

    GPU["GPU"]

    MODEL["Training Model"]

    METRICS["Metrics"]

    CHECKPOINT["Checkpoint"]

    STORAGE --> DATASET
    DATASET --> TRANSFORM
    TRANSFORM --> WORKERS
    WORKERS --> COLLATE
    COLLATE --> PIN
    PIN --> GPU
    GPU --> MODEL
    MODEL --> METRICS
    MODEL --> CHECKPOINT

๐Ÿง  Data Pipeline Bottleneck

A training system can be represented as:

Data Preparation
       โ†“
Data Transfer
       โ†“
GPU Computation

If:

Data Preparation Time
>
GPU Computation Time

the GPU may sit idle.

GPU Utilization

โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘
      โ†‘
   Waiting for Data

The goal is to overlap:

Data Preparation

with:

Model Computation

๐Ÿง  Training Throughput

A simplified view:

[ Throughput = \frac{Samples}{Second} ]

Improving throughput may involve:

Larger / Better Batch Sizes
+
Parallel Data Loading
+
Pinned Memory
+
Efficient Preprocessing
+
Faster Storage
+
GPU Utilization

๐Ÿง  DataLoader Performance Tuning

When optimizing a pipeline, measure:

Batch Loading Time
GPU Transfer Time
Forward Time
Backward Time
Optimizer Time
GPU Utilization
CPU Utilization
Memory Usage

Do not optimize only the model.

The input pipeline can become the bottleneck.


๐Ÿงช Measuring Data Loading

A simple diagnostic:

import time


start = time.perf_counter()

for batch in train_loader:

    end = time.perf_counter()

    print(
        "Batch load time:",
        end - start
    )

    start = time.perf_counter()

    # Training step here

For serious production profiling, use PyTorch profiling and system-level observability rather than relying only on simple timers.


๐Ÿง  Data Loading and Augmentation

For Computer Vision:

Image
 โ†“
Decode
 โ†“
Resize
 โ†“
Random Crop
 โ†“
Random Flip
 โ†“
Normalize
 โ†“
Tensor
 โ†“
Batch
 โ†“
GPU

This pipeline is directly connected to future CNN training chapters.


๐Ÿง  Example Image Dataset

from torch.utils.data import Dataset


class ImageDataset(
    Dataset
):

    def __init__(
        self,
        image_paths,
        labels,
        transform=None
    ):

        self.image_paths = image_paths
        self.labels = labels
        self.transform = transform

    def __len__(
        self
    ):

        return len(
            self.image_paths
        )

    def __getitem__(
        self,
        index
    ):

        image = load_image(
            self.image_paths[index]
        )

        label = self.labels[
            index
        ]

        if self.transform:

            image = self.transform(
                image
            )

        return (
            image,
            label
        )

The exact image-loading implementation depends on the chosen vision library.


๐Ÿง  Data Pipeline Responsibility Boundaries

A clean design can separate:

Dataset
    โ†“
Sample Access

Transform
    โ†“
Sample-Level Processing

DataLoader
    โ†“
Batching / Sampling / Workers

Training Loop
    โ†“
Model Optimization

Avoid putting:

Optimizer Logic
Loss Logic
Model Logic

inside the Dataset.


๐Ÿง  Reproducibility

Data loading can influence reproducibility.

Important factors include:

Random Seeds
+
Shuffling
+
Worker Seeds
+
Data Augmentation
+
Sampler State
+
CUDA Determinism

A reproducible training system must control randomness carefully.


๐Ÿงช Setting a Basic Seed

import torch


torch.manual_seed(
    42
)

For a complete production experiment, additional randomness sources may need to be controlled depending on the libraries and hardware being used.


โš  Reproducibility vs Performance

Some deterministic configurations can reduce performance.

Therefore:

Maximum Reproducibility

and:

Maximum Performance

may sometimes involve trade-offs.

This should be an explicit engineering decision.


๐Ÿง  Distributed Training Consideration

In distributed training, each worker/process should generally receive the appropriate subset of data.

A common mechanism is:

DistributedSampler

Conceptually:

flowchart TD

    DATA["Global Dataset"]

    S1["Worker / GPU 1"]
    S2["Worker / GPU 2"]
    S3["Worker / GPU 3"]
    S4["Worker / GPU 4"]

    DATA --> S1
    DATA --> S2
    DATA --> S3
    DATA --> S4

The data pipeline therefore becomes part of distributed training architecture.


๐Ÿง  Distributed Data Pipeline

Global Dataset
      โ†“
Distributed Sampler
      โ†“
Process 1 โ†’ GPU 1
Process 2 โ†’ GPU 2
Process 3 โ†’ GPU 3
Process 4 โ†’ GPU 4

Each process trains on its assigned portion of the data.


๐Ÿง  DataLoader Configuration Checklist

When designing a DataLoader, evaluate:

โ˜ batch_size
โ˜ shuffle
โ˜ sampler
โ˜ num_workers
โ˜ pin_memory
โ˜ persistent_workers
โ˜ drop_last
โ˜ collate_fn
โ˜ worker initialization
โ˜ memory usage
โ˜ storage throughput
โ˜ GPU transfer efficiency

โš  Common DataLoader Mistakes

Avoid:

  • Loading the entire dataset into GPU memory unnecessarily
  • Using an excessively large batch size
  • Setting num_workers arbitrarily high
  • Using shuffle=True for validation without reason
  • Forgetting drop_last requirements for specific architectures
  • Ignoring variable-length samples
  • Using an inefficient collate_fn
  • Performing expensive preprocessing synchronously when it could be parallelized
  • Excessive CPU/GPU data transfers
  • Ignoring pinned memory when appropriate
  • Assuming more workers always means better performance
  • Forgetting distributed sampling in distributed training
  • Introducing nondeterministic preprocessing without understanding its impact
  • Performing heavy database/network calls for every sample without proper caching or batching

๐Ÿง  End-to-End Mental Model

The complete PyTorch Deep Learning workflow can be summarized as:

                 DATA
                  โ”‚
                  โ–ผ
              DATASET
                  โ”‚
                  โ–ผ
             DATALOADER
                  โ”‚
            โ”Œโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”
            โ”‚           โ”‚
        BATCHING      WORKERS
            โ”‚           โ”‚
            โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                  โ–ผ
             CPU MEMORY
                  โ”‚
                  โ–ผ
            GPU TRANSFER
                  โ”‚
                  โ–ผ
               MODEL
                  โ”‚
                  โ–ผ
             PREDICTION
                  โ”‚
                  โ–ผ
                LOSS
                  โ”‚
                  โ–ผ
              AUTOGRAD
                  โ”‚
                  โ–ผ
              GRADIENTS
                  โ”‚
                  โ–ผ
              OPTIMIZER
                  โ”‚
                  โ–ผ
          UPDATED PARAMETERS
                  โ”‚
                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ MODEL

This is one of the most important mental models for PyTorch engineering.


๐Ÿข Enterprise Perspective

In enterprise Deep Learning systems, model architecture is only one part of the system.

A production training platform must consider:

Data Access
+
Data Versioning
+
Preprocessing
+
Dataset Construction
+
Batching
+
Sampling
+
GPU Transfer
+
Training
+
Checkpointing
+
Experiment Tracking
+
Monitoring

A poorly designed data pipeline can make an expensive GPU cluster underutilized.

Therefore:

Training performance is a system-level concern, not only a model-level concern.


Production Insight

A powerful GPU cannot compensate for a poorly designed input pipeline.

If the GPU is waiting for data, increasing GPU size may simply increase cost without increasing useful throughput.

Always measure:

Data Loading
      โ†“
CPU Processing
      โ†“
CPU โ†’ GPU Transfer
      โ†“
GPU Computation

Then identify the actual bottleneck before optimizing.


๐Ÿง  Production Optimization Strategy

A practical optimization sequence is:

flowchart TD

    START["Training Pipeline"]

    PROFILE["Profile"]

    BOTTLENECK{"Identify Bottleneck"}

    DATA["Optimize Data Access"]

    CPU["Optimize CPU Processing"]

    LOADER["Tune DataLoader"]

    TRANSFER["Optimize CPU โ†’ GPU Transfer"]

    GPU["Optimize GPU Computation"]

    START --> PROFILE
    PROFILE --> BOTTLENECK

    BOTTLENECK -->|Storage / Data| DATA
    BOTTLENECK -->|CPU| CPU
    BOTTLENECK -->|Loading| LOADER
    BOTTLENECK -->|Transfer| TRANSFER
    BOTTLENECK -->|Model / GPU| GPU

    DATA --> PROFILE
    CPU --> PROFILE
    LOADER --> PROFILE
    TRANSFER --> PROFILE
    GPU --> PROFILE

The key principle is:

Measure โ†’ Identify โ†’ Optimize โ†’ Measure Again.


๐Ÿงช Practical Exercise 1 โ€” Autograd

Create:

x = torch.tensor(
    5.0,
    requires_grad=True
)

Calculate:

[ y=3x3+2x2+x ]

Then compute:

y.backward()

and inspect:

x.grad

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

Create a tensor with:

requires_grad=True

Perform two backward passes without clearing the gradient.

Observe the result.

Then repeat using:

x.grad.zero_()

and compare.


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

Create a Dataset representing:

1000 samples
10 features
1 target

Implement:

__len__()
__getitem__()

Verify:

len(dataset)
dataset[0]

๐Ÿงช Practical Exercise 4 โ€” DataLoader

Create:

DataLoader(
    dataset,
    batch_size=32,
    shuffle=True
)

Inspect:

Batch Shape
Number of Batches
Last Batch Size

๐Ÿงช Practical Exercise 5 โ€” drop_last

Compare:

drop_last=False

with:

drop_last=True

using:

Dataset Size = 100
Batch Size = 32

Observe the number and sizes of batches.


๐Ÿงช Practical Exercise 6 โ€” DataLoader Workers

Benchmark:

num_workers=0
num_workers=2
num_workers=4

Measure:

Batch Loading Time
Epoch Time
CPU Usage
GPU Utilization

Do not assume the highest worker count is best.


๐Ÿงช Practical Exercise 7 โ€” Custom collate_fn

Create variable-length sequences:

[1, 2, 3]

[4, 5]

[6, 7, 8, 9]

Implement a custom collate_fn that pads them into a common batch shape.


๐Ÿงช Practical Exercise 8 โ€” GPU Pipeline

Build:

Dataset
 โ†“
DataLoader
 โ†“
GPU Transfer
 โ†“
PyTorch Model
 โ†“
Loss
 โ†“
Backward
 โ†“
Optimizer

Measure:

Data Loading
GPU Transfer
Forward
Backward
Total Batch Time

๐Ÿงช Practical Exercise 9 โ€” End-to-End Classifier

Build a complete classifier using:

Custom Dataset
DataLoader
nn.Module
CrossEntropyLoss
AdamW
Autograd
GPU
Validation Loop
Checkpointing

Track:

Training Loss
Validation Loss
Training Accuracy
Validation Accuracy
Epoch Time

๐Ÿง  Interview Questions

Beginner

1. What is PyTorch Autograd?

Autograd is PyTorch's automatic differentiation system used to compute gradients for tensors involved in differentiable computations.

2. What does requires_grad=True mean?

It tells PyTorch to track operations involving the tensor so gradients can be calculated.

3. What does backward() do?

It performs reverse-mode automatic differentiation through the computational graph to calculate gradients.

4. Why do we call optimizer.zero_grad()?

Because gradients accumulate by default in PyTorch.

5. What is a Dataset?

A Dataset provides access to individual samples and their corresponding targets.

6. What are __len__() and __getitem__()?

__len__() reports the number of samples, while __getitem__() retrieves an individual sample.

7. What is a DataLoader?

A DataLoader provides an iterable over a Dataset and handles batching, shuffling, sampling, and optionally parallel data loading.


Intermediate

8. What is the difference between Dataset and DataLoader?

Dataset defines how individual samples are obtained; DataLoader defines how those samples are organized and delivered during iteration.

9. What is a map-style Dataset?

A Dataset that provides index-based access through __getitem__() and generally implements __len__().

10. What is an IterableDataset?

A dataset that provides samples through iteration using __iter__() and is useful for streaming or sequential data sources.

11. Why use num_workers?

To allow multiple worker processes to prepare data concurrently, potentially reducing input pipeline bottlenecks.

12. What is pin_memory?

It enables the DataLoader to place CPU tensors in pinned host memory, which can improve CPU-to-GPU transfer performance in appropriate CUDA workloads.

13. What is collate_fn?

It defines how individual samples are combined into a batch.

14. Why use drop_last=True?

It discards an incomplete final batch, which can be useful when consistent batch sizes are required.

15. Why shuffle training data?

To reduce undesirable ordering effects and generally provide better randomized mini-batches during optimization.


Advanced

16. Why can a GPU remain underutilized even when the model is computationally large?

Because the input pipeline may be too slow. The GPU may spend time waiting for data, preprocessing, or CPU-to-GPU transfers.

17. How would you diagnose a DataLoader bottleneck?

Measure:

Batch Loading Time
CPU Utilization
GPU Utilization
Transfer Time
Training Step Time

and profile the complete pipeline.

18. Why can increasing num_workers make performance worse?

More workers increase process overhead, memory consumption, and potential I/O contention. The optimal value depends on the workload.

19. What is gradient accumulation?

It is the process of accumulating gradients across multiple mini-batches before performing an optimizer update.

20. Why is gradient accumulation useful?

It can simulate a larger effective batch size when the desired batch size cannot fit into available GPU memory.

21. What is the difference between detach() and no_grad()?

detach() disconnects a specific tensor from its computation history, while no_grad() disables gradient tracking for operations executed inside its context.

22. What happens if you call backward() multiple times without clearing gradients?

Gradients accumulate in the relevant leaf tensors.

23. Why are scalar losses convenient for backward()?

A scalar loss naturally represents the single optimization objective and allows PyTorch to initiate reverse-mode differentiation without requiring an explicit upstream gradient.

24. How would you handle variable-length sequences in a DataLoader?

Use a custom collate_fn to pad, pack, or otherwise organize the samples into a batch representation appropriate for the model.

25. How would you design a production PyTorch input pipeline?

Separate:

Data Storage
Dataset
Transforms
Sampling
DataLoader
Collation
Device Transfer
Training
Monitoring

and benchmark the pipeline end-to-end.


๐Ÿ“Œ Key Takeaways

  • PyTorch Autograd automatically calculates gradients required for optimization.
  • requires_grad=True enables gradient tracking.
  • backward() computes gradients through the computational graph.
  • Gradients accumulate by default.
  • optimizer.zero_grad() is normally used before each standard optimization step.
  • Leaf tensors are especially important when inspecting .grad.
  • detach() disconnects tensors from their autograd history.
  • torch.no_grad() disables gradient tracking for a block of computation.
  • torch.inference_mode() is useful for inference workloads.
  • A Dataset defines how individual samples are accessed.
  • Map-style datasets use __getitem__() and generally __len__().
  • Iterable-style datasets use __iter__().
  • TensorDataset is useful when data already exists as tensors.
  • DataLoader converts individual samples into iterable mini-batches.
  • batch_size controls the number of samples in a batch.
  • shuffle=True is commonly used for training datasets.
  • drop_last=True removes incomplete final batches.
  • num_workers can parallelize data loading.
  • pin_memory=True can improve CPU-to-GPU transfer performance in suitable CUDA workloads.
  • collate_fn controls how samples are assembled into batches.
  • Variable-length data often requires custom collation.
  • Distributed training requires careful dataset partitioning and sampling.
  • The data pipeline can become the bottleneck even when the model and GPU are powerful.
  • Production optimization should be driven by profiling rather than assumptions.
  • Dataset, DataLoader, GPU transfer, model execution, autograd, and optimization form one integrated training system.

๐Ÿ“š Further Reading

Continue with:

The next chapter applies these foundations to build complete classification and regression models using both Keras and PyTorch, connecting the concepts learned throughout the Deep Learning foundations and framework chapters.


โžก๏ธ Next Chapter

18. Building Classification and Regression Models


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