Hello! Welcome back to our course.
In the last lesson, we built the two essential components for multi-class classification: the softmax function to generate a probability distribution and the categorical cross-entropy loss to measure our model's error. You implemented numerically stable versions of these in Python, setting a solid foundation for today.
Today, we'll assemble these pieces into a complete, working Softmax Regression model. We will:
- Derive the gradient of the cross-entropy loss with respect to the model's parameters. This is the final piece of the puzzle needed for training.
- Implement the full training loop using gradient descent.
- Combine everything into a
SoftmaxRegressionclass that you can train on a real dataset.
By the end of this lesson, you will have built a powerful multi-class classifier entirely from scratch.
1. The Missing Piece: The Gradient
To train our model using gradient descent, we need to know how to adjust the weights and biases to minimize the loss. This requires calculating the gradient of the loss function, , with respect to these parameters: and .
The derivation involves the chain rule, as the loss is a function of the probabilities (), which are a function of the logits (), which are a function of the weights and biases ().

The full derivation can get quite involved with matrix calculus. However, it leads to a surprisingly simple and elegant result. The gradient of the cross-entropy loss with respect to the pre-activation logits is:
Here, is the vector of predicted probabilities from the softmax function, and is the one-hot encoded vector of the true label. This simple difference, predictions - actuals, is the error signal that drives learning.
To understand how we arrive at this clean result, the following video provides a detailed, step-by-step walkthrough. The presenter's approach of "pulling back the curtain" on how these derivations are often done in practice—by simplifying to scalars and then re-arranging dimensions—will likely resonate with your engineering background.
Lecture 2 - ML Refresher / Softmax Regression
This lecture segment from the Deep Learning Systems course at CMU manually derives the gradient of the cross-entropy loss. It's a fantastic look at the practical, if sometimes 'horrifying', way these gradients are calculated.
Watch from 54:57 to 01:07:34. Focus on the derivation of the gradient of the cross-entropy loss with respect to its input, h (which we call z). The key result is on the slide at 01:03:00, where the gradient is shown to be z - e_y (our y_hat - y_true).
2. From Logit Gradient to Parameter Gradients
Now that we have the gradient with respect to the logits, , we can use the chain rule to find the gradients for our parameters and . Let's denote the gradient as d_scores.
Recall that our logits are calculated by a linear transformation: .
Gradient with respect to Weights :
Using the chain rule, .
The derivative of with respect to is . Therefore:
The dimensions work out: If is and d_scores is , then is , and the resulting gradient is , which matches the shape of our weight matrix .
Gradient with respect to Biases :
Similarly, .
The derivative of with respect to is simply 1. We just need to sum the gradients from d_scores across all samples in the batch.
The result is a vector of shape , matching our bias vector .
The following guide provides a complete from-scratch implementation in NumPy that puts these gradient calculations into practice within a training loop.
Logistic Regression - Made With ML
This article from Made With ML demonstrates a full training loop for multinomial logistic regression using NumPy. It's a great practical reference for our implementation.
Focus on the 'Training' section. Observe how the forward pass, loss calculation, backpropagation (gradient calculation), and weight updates are sequenced inside the loop. Pay special attention to this line: dscores[range(len(y_hat)), y_train] -= 1. This is a clever and efficient NumPy implementation of y_hat - y_true without explicitly creating the one-hot y_true matrix.
3. Implementing Softmax Regression from Scratch
We're ready to combine everything. We'll create a SoftmaxRegression class that encapsulates the model's logic. This class will use the softmax and categorical_cross_entropy functions you built in the previous lesson.
Let's walk through the complete code.
import numpy as np
from sklearn.datasets import make_blobs
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
# Reuse the functions from our previous lesson
def softmax(z):
if z.ndim == 1:
z = z.reshape(1, -1)
z_stable = z - np.max(z, axis=1, keepdims=True)
exp_z = np.exp(z_stable)
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
def categorical_cross_entropy(y_pred_proba, y_true_indices):
n_samples = y_pred_proba.shape[0]
correct_class_probabilities = y_pred_proba[range(n_samples), y_true_indices]
epsilon = 1e-15
correct_class_probabilities = np.clip(correct_class_probabilities, epsilon, 1 - epsilon)
log_likelihoods = -np.log(correct_class_probabilities)
return np.sum(log_likelihoods) / n_samples
class SoftmaxRegression:
"""
Softmax Regression classifier implemented from scratch.
Parameters:
-----------
learning_rate : float
The step size for parameter updates in gradient descent.
n_epochs : int
The number of passes over the training dataset.
"""
def __init__(self, learning_rate=0.1, n_epochs=100):
self.learning_rate = learning_rate
self.n_epochs = n_epochs
self.weights = None
self.bias = None
def fit(self, X, y):
"""
Train the classifier.
Parameters:
-----------
X : np.array, shape (n_samples, n_features)
Training data.
y : np.array, shape (n_samples,)
Target labels (as integer indices).
"""
n_samples, n_features = X.shape
self.n_classes = len(np.unique(y))
# 1. Initialize parameters
self.weights = np.zeros((n_features, self.n_classes))
self.bias = np.zeros((1, self.n_classes))
# Gradient descent loop
for epoch in range(self.n_epochs):
# 2. Compute logits (linear score)
logits = np.dot(X, self.weights) + self.bias
# 3. Compute probabilities using softmax
y_pred_proba = softmax(logits)
# Optional: Compute and print loss for monitoring
loss = categorical_cross_entropy(y_pred_proba, y)
if epoch % 10 == 0:
print(f"Epoch: {epoch}, Loss: {loss:.4f}")
# 4. Compute the gradient of loss w.r.t logits (d_scores)
# This is the y_hat - y_true part.
d_scores = y_pred_proba.copy()
d_scores[range(n_samples), y] -= 1
d_scores /= n_samples # Average gradient over the batch
# 5. Compute gradients for W and b
dW = np.dot(X.T, d_scores)
db = np.sum(d_scores, axis=0, keepdims=True)
# 6. Update parameters
self.weights -= self.learning_rate * dW
self.bias -= self.learning_rate * db
def predict_proba(self, X):
"""Returns class probabilities for each sample."""
logits = np.dot(X, self.weights) + self.bias
return softmax(logits)
def predict(self, X):
"""Returns the predicted class label for each sample."""
probabilities = self.predict_proba(X)
return np.argmax(probabilities, axis=1)
# --- Let's test our model ---
# 1. Generate synthetic data
X, y = make_blobs(n_samples=500, centers=3, n_features=2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=123)
# 2. Instantiate and train the model
model = SoftmaxRegression(learning_rate=0.1, n_epochs=100)
model.fit(X_train, y_train)
# 3. Evaluate the model
y_pred = model.predict(X_test)
accuracy = np.mean(y_pred == y_test)
print(f"\nFinal Test Accuracy: {accuracy * 100:.2f}%")
When you run this code, you should see the loss decreasing over the epochs and a final test accuracy of 100%. This is because make_blobs creates linearly separable data, which is the ideal scenario for Softmax Regression.

Test your understanding!
In our fit method, we have the line d_scores /= n_samples. Why do we divide the gradient by the number of samples? What would happen during training if we removed this line and didn't adjust the learning rate?
Show answer
We divide by n_samples because our loss function categorical_cross_entropy calculates the average loss over the batch. To be consistent, the gradient must also be the average gradient.
If we removed this line, the gradient would be the sum of the gradients for each sample. This would make the magnitude of the gradient dependent on the batch size. For a large batch, the gradient would be huge, and our fixed learning rate would likely cause the parameter updates to be too large. The optimization process would become unstable, with the loss likely exploding instead of converging. We would need to significantly decrease the learning rate to compensate.
Conclusion
Congratulations! You have successfully implemented a complete Softmax Regression classifier from the ground up. You have now mastered a cornerstone algorithm for multi-class classification.
Key Takeaways:
- The gradient of the cross-entropy loss with respect to the logits is a simple and powerful error signal: predicted probabilities minus one-hot encoded true labels.
- Using the chain rule, we can easily find the gradients for the weights () and biases ().
- A full training loop consists of a forward pass to get predictions, calculation of gradients (backpropagation), and updating parameters using gradient descent.
- By structuring your implementation in a class, you've created a reusable and organized piece of code, which is excellent engineering practice.
Preview of the Next Lesson:
Our current model works perfectly on clean, linearly separable data. But what happens when the data is noisy or the model is too complex? It can lead to overfitting. In the next lesson, we will explore Regularization. You will learn how to modify the cost function and its gradient to include L1 (Lasso) and L2 (Ridge) penalties, which help prevent overfitting and improve the generalization of your models.