Gradient Descent: Batch, Stochastic, and Mini-batch

You can access the code used for this blog post in a Google Colab Notebook here.

Imagine that we are standing on a mountain with a very thick fog which render us with zero visibility. And our goal is to descend the mountain. To descend the mountain, we “feel” the slope of the ground around us and take a step in the direction where the ground slopes down the most.

A more formal definition: Gradient descent is an iterative optimization algorithm used in machine learning and deep learning to minimize a cost function (mountain). It updates model parameters (weights and biases) by calculating the gradient (slope) of the loss function and moving in the opposite direction of that slope, effectively descending toward the lowest error (descending the mountain).

In this blog post, we will look into three variants of the gradient descent algorithm.

Note: For the rest of the blog post and the screenshots, I used the MNIST dataset. Reading my earlier post “Building an Image Classifier from Scratch” can give you more context.

Batch Gradient Descent

In Batch Gradient Descent, we calculate the gradient of the cost function $J(\theta)$ for the entire dataset of size $m$. Let $L$ be the loss function:

$$ \nabla_{\theta}J(\theta) = \frac{1}{m}\sum_{i=1}^{m} \nabla_{\theta}L $$

Then we perform the update (descent):

$$ \theta = \theta – \alpha \cdot \nabla_{\theta}J(\theta) $$

Where:

  • $\theta$ represents the model parameters $W$ (weights) and $b$ (biases).
  • $\alpha$ is the learning rate

For example, if we have 100,000 data samples in our training set, it must multiple a weight matrix by the entire dataset matrix BEFORE a single update.

Here’s how the Cost looks like per gradient descent for Batch Gradient Descent.

We can see that learning is rather smooth.

Stochastic Gradient Descent (SGD)

In SGD, we approximate this by picking one random example $(x^{(i)}, y^{(i)})$ at each step. The update rule becomes:

$$ \theta = \theta – \alpha \cdot \nabla_{\theta}L $$

Where:

  • $\theta$: The model parameters (weights and biases).
  • $\alpha$: The learning rate.
  • $L$: The loss function for a single observation.

For SGD, we only multiply the weight by ONE training sample at a time.

It is “Stochastic” (random) because a single data point is a “noisy” approximation of the whole dataset.

For SGD, let’s look at two different graphs.

  1. Cost vs Epoch

2. Cost vs Gradient Descent

The first graph is what the cost look like in every epoch. In the first graph, we can see that the learning process is not smooth. This is even more evident in the second graph, where we look at the cost on every gradient descent. We can see that there’s a lot of “noise”, especially in the early part of training. This is normal because we are updating the gradient in every data sample, so for example, our first training sample is the digit “1”, then the model will correct the weights towards the digit “1”, then if it sees the training sample “0” next, the cost will be higher since visually “1” are all lines without curve so it’s “visually” far from “0”.

Mini-batch SGD

The problem with Batch Gradient Descent is training takes a lot of time because it has to process the entire dataset for every single step.

Mini-batch SGD takes the best of both worlds, instead of being extreme and just taking “one” data example, we get a smaller batch of the dataset. For example, if we have 100,000 data samples in our training set, we take 1024 random samples from it.

The update rule for a single mini-batch $B_k$ is:

$$ \theta = \theta – \alpha \cdot \frac{1}{n} \sum_{i \in B_k} \nabla_{\theta} L $$

Where:

  • $n$: The batch size (number of samples in the mini-batch).
  • $\sum_{i \in B_k}$: The sum of gradients only for the samples in that specific batch.

Again, we also look at two graphs for the Mini-batch:

  1. Cost vs Epoch

2. Cost vs Gradient Descent

We can see that there are still “noise” but it’s not as much as with SGD.

The Trade-Offs

As in almost all things, choosing which variation to use comes down to trade-off between hardware limitation and convergence speed (amount of time to descend the mountain).

Batch GD requires loading the entire dataset into RAM/VRAM. For MNIST or other smaller datasets, this is fine. But normally, training datasets are hundreds of GBs and loading all of them will require a very powerful and very expensive computer or more often impossible with our current technology.

SGD wastes GPU utilization because we are only computing one sample at a time. Our GPUs are very good at doing matrix computations. Having too much “noise” also often prevents the algorithm to converge, but instead it just “wanders” around.

Mini-batch is the “golden-standard”. The size of the batch is a hyperparameter and can be tuned depending on the hardware being used to train. It also takes advantage of GPUs through “matrix-matrix” multiplications. And the “noise” due to changing of batch often helps our algorithm to get out of being stuck to a local minima.

MethodSpeed per stepAccuracy per stepMemory Usage
BatchSlowPerfect slope (Smooth)High
SGDFastNoisy slope (Wanders)Low
Mini-batchModerateGood approximationModerate

Results in MNIST

I also trained an image classifier using the different methods and here are the results.

Batch GD Accuracy: 91.23%
SGD Accuracy: 89.76%
Mini-batch GD Accuracy: 96.58%

For Batch GD, I can’t even use the whole dataset because the training becomes unbearably slow.

The step update in SGD is fast but convergence is slow and as you can see it’s accuracy is the lowest.

Lastly, Mini-batch got the highest accuracy. Mini-batch generalizes better than Batch GD because the slight noise helps it escape bad local minima.

More reads

If you enjoy this post, you might enjoy my other posts:

Full Python Implementation

In case you want to see the code implementation of the three variants of Gradient Descent.

class GradientDescent:

  def __init__(self):
    np.random.seed(42)
    # Layer 1: 784 Inputs -> 128 Hidden Neurons
    self.W1 = np.random.randn(128, 784) * 0.01
    self.b1 = np.zeros((128, 1))

    # Layer 2: 128 Hidden Neurons -> 10 Outputs
    self.W2 = np.random.randn(10, 128) * 0.01
    self.b2 = np.zeros((10, 1))

    self.epsilon = 1e-8

    self.cost      = []
    self.cost_sgd  = []
    self.cost_mini = []

    self.sgd_cost_per_gd = []
    self.mini_cost_per_gd = []

  def sigmoid(self, z):
    # np.clip prevents overflow/underflow errors in exp
    z_clipped = np.clip(z, -15, 15)
    return 1 / (1 + np.exp(-z_clipped))

  def sigmoid_derivative(self, a):
    return a * (1 - a)

  def softmax(self, z):
    max_logit = np.max(z, axis=0, keepdims=True)
    capped_z = np.exp(z - max_logit)
    return capped_z / np.sum(capped_z, axis=0, keepdims=True)

  def forward(self, X):
    # Layer 1 (Hidden Layer)
    self.Z1 = np.dot(self.W1, X) + self.b1
    self.A1 = self.sigmoid(self.Z1)

    # Layer 2 (Output Layer)
    self.Z2 = np.dot(self.W2, self.A1) + self.b2
    self.A2 = self.softmax(self.Z2)

    return self.A2

  # Batch Gradient Descent
  def train(self, X, y, learning_rate=0.01, epochs=1):
    m = X.shape[0]

    # One-hot encoding
    y_one_hot = np.zeros((10, m))
    for i in range(m):
        y_one_hot[y[i], i] = 1

    for epoch in range(epochs):
      self.forward(X.T)

      # --- Backprop for Layer 2 (Output) ---
      dZ2 = self.A2 - y_one_hot
      dW2 = (1/m) * np.dot(dZ2, self.A1.T)
      db2 = (1/m) * np.sum(dZ2, axis=1, keepdims=True)

      # --- Backprop for Layer 1 (Hidden) ---
      # We multiply by W2 to see how much the hidden layer contributed to the error
      dA1 = np.dot(self.W2.T, dZ2)
      dZ1 = dA1 * self.sigmoid_derivative(self.A1)
      dW1 = (1/m) * np.dot(dZ1, X)
      db1 = (1/m) * np.sum(dZ1, axis=1, keepdims=True)

      # Gradient Descent Update
      self.W2 = self.W2 - (learning_rate * dW2)
      self.b2 = self.b2 - (learning_rate * db2)
      self.W1 = self.W1 - (learning_rate * dW1)
      self.b1 = self.b1 - (learning_rate * db1)

      cost = -np.sum(y_one_hot * np.log(self.A2 + self.epsilon)) / m # adding epsilon prevents log(0)
      self.cost.append(cost)

      if epoch % 1000 == 0:
        print(f"Epoch: {epoch}, Cost: {cost:.4f}")

  # Stochastic Gradient Descent
  def train_sgd(self, X, y, learning_rate=0.01, epochs=1):
    m = X.shape[0]

    for epoch in range(epochs):
      # 1. Shuffle at the start of every epoch.
      indices = np.random.permutation(m)
      X_shuffled = X[indices]
      y_shuffled = y[indices]

      # We want to track the average cost per epoch
      epoch_cost = 0
      update_ctr = 0

      for i in range(m):
        # 2. Reshape single image to (784, 1) and label to one-hot (10, 1)
        x_i = X_shuffled[i].reshape(-1, 1)
        y_i = np.zeros((10, 1))
        y_i[y_shuffled[i]] = 1

        self.forward(x_i)

        # --- Backprop for Layer 2 (Output) ---
        dZ2 = self.A2 - y_i
        dW2 = np.dot(dZ2, self.A1.T)
        db2 = np.sum(dZ2, axis=1, keepdims=True)

        # --- Backprop for Layer 1 (Hidden) ---
        # We multiply by W2 to see how much the hidden layer contributed to the error
        dA1 = np.dot(self.W2.T, dZ2)
        dZ1 = dA1 * self.sigmoid_derivative(self.A1)
        dW1 = np.dot(dZ1, x_i.T)
        db1 = np.sum(dZ1, axis=1, keepdims=True)

        # In SGD, we immediately update the weights on each data sample.
        self.W2 = self.W2 - (learning_rate * dW2)
        self.b2 = self.b2 - (learning_rate * db2)
        self.W1 = self.W1 - (learning_rate * dW1)
        self.b1 = self.b1 - (learning_rate * db1)

        cost = -np.sum(y_i * np.log(self.A2 + self.epsilon)) # adding epsilon prevents log(0)
        self.sgd_cost_per_gd.append(cost)
        epoch_cost += cost
        update_ctr += 1

      avg_epoch_cost = epoch_cost / update_ctr
      self.cost_sgd.append(avg_epoch_cost)

      if epoch % 100 == 0:
        print(f"Epoch: {epoch}, Cost: {avg_epoch_cost:.4f}")

  # Mini Batch
  def train_mini(self, X, y, learning_rate=0.01, epochs=100, batch_size=256):
    m = X.shape[0]

    for epoch in range(epochs):
      # 1. Shuffle at the start of every epoch.
      indices = np.random.permutation(m)
      X_shuffled = X[indices]
      y_shuffled = y[indices]

      epoch_cost = 0
      update_ctr = 0

      for i in range(0, m, batch_size):
        # 2. Reshape single image to (784, 1) and label to one-hot (10, 1)
        x_batch = X_shuffled[i : i + batch_size]
        y_batch_labels = y_shuffled[i : i + batch_size]
        n = x_batch.shape[0] # Actual size of the current batch

        y_batch = np.zeros((10, n))
        for idx, val in enumerate(y_batch_labels):
          y_batch[val, idx] = 1

        # 3. Forward pass
        a2 = self.forward(x_batch.T)

        # 4. Backprop
        dZ2 = a2 - y_batch
        dW2 = (1/n) * np.dot(dZ2, self.A1.T)
        db2 = (1/n) * np.sum(dZ2, axis=1, keepdims=True)

        # --- Backprop for Layer 1 (Hidden) ---
        # We multiply by W2 to see how much the hidden layer contributed to the error
        dA1 = np.dot(self.W2.T, dZ2)
        dZ1 = dA1 * self.sigmoid_derivative(self.A1)
        dW1 = (1/n) * np.dot(dZ1, x_batch)
        db1 = (1/n) * np.sum(dZ1, axis=1, keepdims=True)

        self.W2 = self.W2 - (learning_rate * dW2)
        self.b2 = self.b2 - (learning_rate * db2)
        self.W1 = self.W1 - (learning_rate * dW1)
        self.b1 = self.b1 - (learning_rate * db1)

        # Let's keep track of the cost.
        cost = -np.sum(y_batch * np.log(a2 + self.epsilon)) / n
        self.mini_cost_per_gd.append(cost)
        epoch_cost += cost
        update_ctr += 1

      avg_epoch_cost = epoch_cost / update_ctr
      self.cost_mini.append(avg_epoch_cost)

      if epoch % 100 == 0:
        print(f"Epoch: {epoch}, Avg Cost: {avg_epoch_cost:.4f}")