TensorFlow Batching and Shuffling

Batching and shuffling are two pipeline operations that control how training data reaches the model. The batch size determines how many samples the model sees before updating its weights. Shuffling prevents the model from learning the order of examples rather than the patterns within them. Both decisions affect training speed, convergence stability, and final model accuracy.

Why Process Data in Batches

Training on one sample at a time (batch size 1) updates weights extremely frequently but with very noisy gradient estimates. Training on the entire dataset at once (full batch) gives a perfect gradient but requires all data to fit in GPU memory and makes only one weight update per epoch. Batches strike the balance — enough samples for a stable gradient, small enough to fit in memory and update weights frequently.

Diagram — Effect of Batch Size on Gradient Estimates:

Full Batch (all 10,000 samples):
  Gradient direction: ──────────────────► exact direction to minimum
  Updates per epoch: 1
  Memory needed: entire dataset

Mini-batch (batch=32):
  Gradient direction: ──~──~────~───────► noisy but roughly correct
  Updates per epoch: 313
  Memory needed: 32 samples at a time

Stochastic (batch=1):
  Gradient direction: ~─~~─~─~~─~~~~~~~► very noisy
  Updates per epoch: 10,000
  Memory needed: 1 sample

Batching in tf.data

import tensorflow as tf
import numpy as np

x = np.random.random((1000, 20)).astype('float32')
y = np.random.randint(0, 3, size=1000)

dataset = tf.data.Dataset.from_tensor_slices((x, y))

# Basic batch
batched = dataset.batch(32)

# Inspect the first batch
for features, labels in batched.take(1):
    print(features.shape)   # (32, 20)
    print(labels.shape)     # (32,)

drop_remainder Parameter

# 1000 samples ÷ 32 = 31 full batches + 1 partial batch of 8
# drop_remainder=True drops the partial batch
dataset.batch(32, drop_remainder=True)
# → Always produces batches of exactly 32
# → Useful when model architecture requires a fixed batch size

# drop_remainder=False (default) keeps the partial batch
dataset.batch(32, drop_remainder=False)
# → Last batch may be smaller

Shuffling in tf.data

# buffer_size controls how many elements are loaded into memory for shuffling
# A larger buffer gives better randomness but uses more memory

# Perfect shuffle (if data fits in memory)
dataset = dataset.shuffle(buffer_size=len(x))  # buffer = full dataset

# Approximate shuffle (memory-limited)
dataset = dataset.shuffle(buffer_size=5000)    # Hold 5000, draw randomly

# Reproducible shuffle (for debugging)
dataset = dataset.shuffle(buffer_size=1000, seed=42)

How the Shuffle Buffer Works

Dataset (ordered):  [1][2][3][4][5][6][7][8][9][10]...

buffer_size = 4: fills buffer with first 4 elements

Buffer: [1][2][3][4]
Draw randomly → outputs [3]
Fills slot: [1][2][5][4]
Draw randomly → outputs [1]
Fills slot: [6][2][5][4]
...and so on

Small buffer = elements only shuffled with nearby elements
Large buffer = elements shuffled across the whole dataset

The Correct Order: Shuffle → Batch → Prefetch

# CORRECT order
dataset = (
    tf.data.Dataset.from_tensor_slices((x, y))
    .shuffle(buffer_size=1000)    # Shuffle individual examples first
    .batch(32)                    # Then group into batches
    .prefetch(tf.data.AUTOTUNE)   # Prepare next batch in background
)

# WRONG order — shuffles entire batches, not individual examples
dataset = (
    tf.data.Dataset.from_tensor_slices((x, y))
    .batch(32)
    .shuffle(100)    # Shuffles 100 pre-formed batches — much weaker
)

Choosing the Right Batch Size

Batch Size   Behavior                        Typical Use
────────────────────────────────────────────────────────────────
1            Most noisy gradients, slowest   Rarely used
8–16         High noise, many updates        Very small datasets
32           Good default balance            Most tasks (start here)
64           Stable, fewer updates           Medium datasets, large models
128–256      Very stable, fewer updates      Large datasets, fast GPUs
512+         Needs large learning rate adj.  Distributed training, TPUs
────────────────────────────────────────────────────────────────

Learning Rate Scaling with Batch Size

When you double the batch size, the gradient estimate becomes twice as stable — essentially you get double the signal per step. To compensate, the learning rate should also scale up proportionally. This linear scaling rule works well in practice.

# Linear scaling rule
base_batch_size = 32
base_lr = 0.001

new_batch_size = 128
new_lr = base_lr * (new_batch_size / base_batch_size)
# new_lr = 0.001 × 4 = 0.004

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

Re-Shuffling Every Epoch

When you use model.fit() with epochs > 1 on a tf.data.Dataset, TensorFlow automatically reshuffles the data at the start of each epoch — as long as reshuffle_each_iteration=True (the default).

# This re-shuffles at every epoch start (default behavior)
dataset = tf.data.Dataset.from_tensor_slices((x, y)).shuffle(1000)

# To disable re-shuffling (rarely needed)
dataset = tf.data.Dataset.from_tensor_slices((x, y)).shuffle(
    1000, reshuffle_each_iteration=False
)

Gradient Accumulation: Simulating Large Batches

If you want the stability of a large batch but cannot fit it in GPU memory, gradient accumulation simulates a large effective batch by summing gradients from several small batches before applying one update.

accumulation_steps = 4   # Effective batch = 32 × 4 = 128

for step, (x_batch, y_batch) in enumerate(train_ds):
    with tf.GradientTape() as tape:
        preds = model(x_batch, training=True)
        loss = loss_fn(y_batch, preds) / accumulation_steps  # Scale loss

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

    # Accumulate gradients
    if step == 0:
        accumulated = grads
    else:
        accumulated = [a + g for a, g in zip(accumulated, grads)]

    # Apply update every N steps
    if (step + 1) % accumulation_steps == 0:
        optimizer.apply_gradients(zip(accumulated, model.trainable_variables))
        accumulated = None

Batching Sequences of Variable Length

# Sequences can have different lengths — padding aligns them in a batch
sequences = [
    [1, 2, 3],
    [4, 5],
    [6, 7, 8, 9]
]

# padded_batch pads shorter sequences to the length of the longest in the batch
seq_ds = tf.data.Dataset.from_generator(
    lambda: iter(sequences),
    output_signature=tf.RaggedTensorSpec(shape=[None], dtype=tf.int32)
)
padded = seq_ds.padded_batch(batch_size=3, padded_shapes=[None])
for batch in padded:
    print(batch)
# [[1, 2, 3, 0],
#  [4, 5, 0, 0],
#  [6, 7, 8, 9]]

Getting batching and shuffling right is foundational to stable training. A model that sees data in the same order every epoch learns the sequence rather than the content. A batch size that is too large produces overly smooth gradients that miss fine-grained patterns. These two simple settings have a larger effect on training quality than most architectural choices. The next topic begins the CNN section by covering Conv2D layers in detail.

Leave a Comment

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