Mini-batches and SGD
Train on small random batches instead of everything at once.
Create a free account Try part of it here
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
- One big step or many small ones
- Three batch sizes, head to head
- Why a rough direction is good enough
- Worked example: spoonfuls of gradient
- The DataLoader pattern
- 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 . Call the batch size . The brackets mean "round up to a whole number".
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")
A batch as big as the whole training set gives 1 step per epoch. Batches of 16 give 90.
A small batch gives many noisy steps per epoch. A large batch gives few smooth ones.
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.
import math print(math.ceil(1437 / 128) * 5)
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 , from example 2 , and so on up to . The batch's gradient is their average.
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 . 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: 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}")
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 batches | Smaller batches |
|---|---|
| smoother, more accurate gradient | noisier gradient (which can help escape poor regions and generalize) |
| fewer steps per epoch | more steps per epoch |
| use the GPU efficiently, until memory runs out | may leave a GPU underused |
| usually allow a larger learning rate | usually 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.
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 . Each one gives an average loss, and you add them up after dividing each by .
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.
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
Try a quiz question
Why shuffle the training data every epoch?