Index Ai Academy
Phase 4 · Deep learning · Training

Mini-batches and SGD

Train on small random batches instead of everything at once.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Nobody works out the gradient over a whole dataset before taking a single step. LLMs train on trillions of tokens, so that would take far too long. Instead, each step uses a small random batch of examples. The batch gives a rough but cheap guess at the true gradient. This is called stochastic gradient descent. The batch size you pick affects speed, memory and stability. It even affects how well the model does on data it has never seen.

What you'll be able to do

  • Explain full-batch, stochastic and mini-batch gradient descent, and epochs vs steps
  • Train with a shuffled DataLoader
  • Describe how batch size trades speed, noise and memory
  • Use gradient accumulation to imitate a larger batch

What it covers

  1. One big step or many small ones
  2. Three batch sizes, head to head
  3. Why a rough direction is good enough
  4. Worked example: spoonfuls of gradient
  5. The DataLoader pattern
  6. When the batch you want doesn't fit: gradient accumulation

One big step or many small ones

Picture yourself walking down a foggy hill. You could stop and survey the whole hillside before every step. Your direction would be perfect, but you'd barely move. Or you could glance at the ground near your feet and step. Each glance is a little off, but you take many more steps.

Training a network is the same choice. You know from the training-loop lesson that the gradient says which way to nudge each weight to lower the loss. The question is how many examples you look at before each nudge. There are three options.

  • Full batch: work out the gradient over every training example, then take one step. The direction is accurate. But you get only one step per pass over the data. And it's impossible when the data doesn't fit in memory.
  • Stochastic (batch size 1): use one example per step. "Stochastic" means random. Each step is very cheap, but its direction is very noisy.
  • Mini-batch (usually 32 to a few thousand): the practical middle. You average the gradient over a small random batch. That average points roughly downhill, and you take many steps per pass.

Two words come up all the time. One step (also called an iteration) is one update of the weights. One epoch is one full pass over the whole training set.

Counting steps

How many steps fit in one epoch? Call the number of training examples NN. Call the batch size BB. The brackets ⌈ ⌉\lceil\ \rceil mean "round up to a whole number".

steps per epoch=⌈NB⌉\text{steps per epoch} = \left\lceil \frac{N}{B} \right\rceil

In plain words: divide the number of examples by the batch size. If it doesn't divide evenly, round up, because the leftover examples form one last, smaller batch.

Take the digits dataset from the training-loop lesson. Its training set has 1,437 examples. With batches of 32, that's 1,437 ÷ 32 ≈ 44.9. Rounded up, an epoch is 45 steps. The first 44 batches hold 32 examples each, and the last one holds the 29 left over.

import math

examples = 1437
for batch_size in [1437, 128, 32, 16]:
    steps = math.ceil(examples / batch_size)
    print(f"batch {batch_size:>4}: {steps:>2} per epoch, {steps * 5:>3} in 5 epochs")
It runs right here in your browser, with nothing to install.

A batch as big as the whole training set gives 1 step per epoch. Batches of 16 give 90.

Try it now

A small batch gives many noisy steps per epoch. A large batch gives few smooth ones.

Quick check
A dataset has 10,000 examples and the batch size is 50. How many steps are in one epoch?
  1. 200
  2. 50
  3. 10,000
  4. 500,000
Show the answer

200

10,000 ÷ 50 = 200 batches, and each batch is one step.

Three batch sizes, head to head

Here is a fair race. It uses the same model, the same learning rate and the same five epochs. Only the batch size changes: the full training set, then 128, then 16. The code prints the steps, the time taken and the accuracy on the validation set (data held back from training). It also plots the training loss after each epoch. Your times will differ from anyone else's.

import time
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target)
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0)
loss_fn = nn.CrossEntropyLoss()

for batch_size in [len(X_train), 128, 16]:
    torch.manual_seed(0)
    model = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10))
    optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
    loader = DataLoader(TensorDataset(X_train, y_train), batch_size=batch_size, shuffle=True)
    curve, steps, start = [], 0, time.time()
    for epoch in range(5):
        for xb, yb in loader:
            optimizer.zero_grad()
            loss_fn(model(xb), yb).backward()
            optimizer.step()
            steps += 1
        with torch.no_grad():
            curve.append(loss_fn(model(X_train), y_train).item())
    with torch.no_grad():
        acc = (model(X_val).argmax(1) == y_val).float().mean().item()
    print(f"batch {batch_size:>4}: {steps:>3} steps, {time.time() - start:.1f}s, val accuracy {acc:.3f}")
    plt.plot(range(1, 6), curve, marker="o", label=f"batch size {batch_size}")
plt.xlabel("epoch")
plt.ylabel("training loss")
plt.legend()

All three runs see the data five times. But the small batches take far more steps, and they get much further. Each of their steps is noisier. Many roughly right steps still beat a few exact ones.

Quick check
In the race above, the training set has 1,437 examples. What does this print: the number of steps the batch-128 run takes in 5 epochs?
import math
print(math.ceil(1437 / 128) * 5)
  1. 12
  2. 60
  3. 5
  4. 55
Show the answer

60

1,437 ÷ 128 ≈ 11.2, which rounds up to 12 steps per epoch. Five epochs make 60 steps. 55 would drop the last, smaller batch each epoch.

Why a rough direction is good enough

Think of tasting soup. You don't drink the whole pot to check the salt. One spoonful from a well-stirred pot tells you almost the same thing. A random batch is that spoonful.

Each example in a batch gives its own gradient. Call the gradient from example 1 g1g_1, from example 2 g2g_2, and so on up to gBg_B. The batch's gradient is their average.

gbatch=g1+g2+⋯+gBBg_{\text{batch}} = \frac{g_1 + g_2 + \cdots + g_B}{B}

In plain words: add up the gradients from every example in the batch. Then divide by how many examples there are. That average is the direction of the step.

Real gradients have one number per weight. To keep it small, pretend there's only one weight. A batch of 4 examples gives gradients 3, 1, 2 and 6. They add up to 12, and 12 ÷ 4 = 3. So the batch's gradient is 3.

grads = [3, 1, 2, 6]               # one gradient per example in the batch
print(sum(grads) / len(grads))     # the batch's gradient: their average

This prints 3.0. Because the batch is picked at random, its average is right on average. Some batches point a bit too far one way, and some a bit too far the other. Over many steps, those errors mostly cancel out. The noise can even help: it can jostle the model out of a poor spot.

Go deeper Why a random batch is right on average

Every example has the same chance of landing in a batch. So the batch average has the same expected value as the average over the whole dataset. Statisticians call this an unbiased estimate. It's the same reason a fair poll of 1,000 voters is right on average about millions.

The size of the error shrinks as the batch grows, but slowly. Its typical size falls with the square root of BB. A batch 100 times bigger has about one tenth of the noise, not one hundredth. (This assumes examples are drawn independently, which is close enough for large datasets.)

Worked example

Worked example: spoonfuls of gradient

Here are 1,000 made-up gradients, one per example, for a model with one weight. The code averages all of them, the full-batch answer. Then it draws five random batches of size 1, 10 and 100, and prints each batch's average.

import random

random.seed(0)
grads = [random.gauss(2.0, 3.0) for _ in range(1000)]   # one made-up gradient per example
full = sum(grads) / len(grads)
print(f"full batch (all 1000): {full:.2f}")

for size in [1, 10, 100]:
    guesses = []
    for _ in range(5):                                   # five different random batches
        batch = random.sample(grads, size)
        guesses.append(round(sum(batch) / size, 2))
    print(f"batch size {size:>3}: {guesses}")
In the lesson you can change this and run it.

The full-batch average is 2.05. Single examples swing wildly, from −2.46 to 6.78, and two even point the wrong way. Batches of 10 stay between 0.52 and 2.81. Batches of 100 stay between 1.85 and 2.16, close to the true value. Bigger batches give steadier steps, but each one costs more to compute.

The DataLoader pattern

In PyTorch, DataLoader does the batching for you. It wraps a dataset and hands out (inputs, targets) pairs, one batch at a time. With shuffle=True, it reshuffles every epoch, so the batches differ on each pass.

That matters. Think of studying with flashcards. If the deck is always in the same order, you start learning the order instead of the cards. In the same way, batches that are identical every epoch can make training go round in circles.

Every PyTorch training loop you'll read has this shape:

loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
for epoch in range(num_epochs):
    for xb, yb in loader:
        optimizer.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        optimizer.step()

The outer loop counts epochs. The inner loop takes one step per batch. By default the last, smaller batch is kept. Pass drop_last=True to skip it instead.

Choosing a batch size

Bigger and smaller batches each have strengths. A GPU is the graphics chip that most training runs on. It's fast when it has lots of work at once, but its memory is limited.

Larger batchesSmaller batches
smoother, more accurate gradientnoisier gradient (which can help escape poor regions and generalize)
fewer steps per epochmore steps per epoch
use the GPU efficiently, until memory runs outmay leave a GPU underused
usually allow a larger learning rateusually need a smaller one

To generalize means to do well on new data the model hasn't seen.

In practice, pick the largest batch that fits comfortably in memory and still trains well. Then tune the learning rate for it. A common rule of thumb: when you scale the batch size up, scale the learning rate up by roughly the same factor. It's a starting point, not a law.

Quick check
A model trains well with batch size 64 and learning rate 0.01. You move to batch size 128. What does the rule of thumb suggest as a starting learning rate?
  1. About 0.02
  2. About 0.64
  3. Keep 0.01 exactly
  4. About 0.005
Show the answer

About 0.02

The batch doubled, so the rule of thumb doubles the learning rate too. Then you'd check that it still trains well.

When the batch you want doesn't fit: gradient accumulation

Sometimes the batch you want is too big for the GPU's memory. There's a neat way around it.

In PyTorch, gradients pile up until you call zero_grad(). Each backward() adds to what's already there. You can use that on purpose. Run several small batches through backward(), and only then call step(). You've now taken one step with the gradient of all those batches combined. Each small batch is called a micro-batch.

One detail matters. Divide each loss by the number of micro-batches, so the result is an average and not a total:

accum = 4                                     # 4 micro-batches of 16 ≈ one batch of 64
optimizer.zero_grad()
for i, (xb, yb) in enumerate(loader):
    (loss_fn(model(xb), yb) / accum).backward()
    if (i + 1) % accum == 0:
        optimizer.step()
        optimizer.zero_grad()

The line if (i + 1) % accum == 0 is true on every fourth micro-batch. That's when the step happens and the gradients are cleared. Call the number of micro-batches kk. Each one gives an average loss, and you add them up after dividing each by kk.

combined=loss1k+loss2k+⋯+losskk\text{combined} = \frac{\text{loss}_1}{k} + \frac{\text{loss}_2}{k} + \cdots + \frac{\text{loss}_k}{k}

In plain words: shrink each micro-batch's loss by the number of micro-batches, then add them up. The total is the average over all of them, the same as one big batch would give.

Say four micro-batches have losses 0.8, 0.6, 0.4 and 0.2. Divided by 4, they become 0.2, 0.15, 0.1 and 0.05. Those add up to 0.5, which is the plain average of the four losses. The gradients add up the same way, because each backward() adds its share.

losses = [0.8, 0.6, 0.4, 0.2]    # four micro-batch losses
accum = 4
total = 0.0
for loss in losses:
    total += loss / accum        # what accumulation adds up
print(round(total, 2))
print(round(sum(losses) / len(losses), 2))   # the plain average

Both lines print 0.5. Without the division, the total would be 2.0, four times too big. That's like quietly multiplying the learning rate by 4 (with plain SGD, as here).

Go deeper When accumulation exactly matches one big batch

Each micro-batch loss is already an average over its own examples. When every micro-batch has the same size, the average of those averages equals the average over all the examples together. So four micro-batches of 16 give the same gradient as one batch of 64. If the last micro-batch is smaller, the match is close but not exact. Layers that look at the whole batch at once, such as batch normalization (a later lesson), also behave a little differently.

LLM training batches are measured in millions of tokens. They're assembled exactly this way, across many GPUs and many accumulation steps.

Quick check
You accumulate over 4 micro-batches but forget to divide each loss by 4. What happens to the step?
  1. It's 4 times too small
  2. It's about 4 times too big, like using a learning rate 4 times higher
  3. The model takes 4 steps instead of 1
  4. Nothing changes, because the optimizer averages for you
Show the answer

It's about 4 times too big, like using a learning rate 4 times higher

Each backward() adds its gradient, so you get the sum of 4 averages instead of their average. With plain SGD, as in this lesson, the step is 4 times as large.

Practice what you just read

  • 7 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

Why shuffle the training data every epoch?

  1. So batches differ each pass and the model doesn't see the same sequence of gradients every time
  2. To make each epoch shorter, because a shuffled batch needs fewer steps to reach the same loss
  3. Because DataLoader requires it, and raises an error if the rows come in the same order twice
  4. To change the labels, so that the model can't simply memorize the answer that each row has

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson