Softmax regression
Generalize logistic regression to multiple classes via the softmax and the cross-entropy loss.
Logistic regression handles two classes. For classes, the natural generalization parameterizes one linear score per class and normalizes with the softmax. Training minimizes cross-entropy, which (as the kl-cross-entropy lesson shows) is maximum likelihood under the categorical distribution.
The model
For an input and classes, the model has a coefficient vector per class. The class probabilities are
The denominator normalizes the scores into a probability distribution (, every ). This is the softmax of the score vector [1].
The model is overparameterized: adding a constant to every leaves the probabilities unchanged. A common fix is to set (reduces to logistic regression when ); production code keeps all parameter vectors and lets regularization tie down the redundancy.
Cross-entropy loss
With one-hot true labels , the negative log-likelihood per example is the cross-entropy:
The log-sum-exp form is exactly the stable softmax of the numerical-methods lesson; production code always shifts by the maximum before exponentiating.
A few score vectors over three classes make the scale concrete (true class is class 0 throughout, so the per-example loss is ):
- Confident and right. Scores softmax to . The model puts on the true class, paying loss nats.
- Hesitant. Scores give : a bare majority on the true class, loss nats, ten times the confident case.
- No opinion. Scores give the uniform , loss nats, the most a three-class model can pay while still ranking the truth first.
The overparameterization from the model section shows up here as shift invariance: adding to every score, , leaves the softmax at unchanged, since the shared constant cancels in the ratio. Only the differences between scores carry information.
Gradient
The gradient with respect to has a clean form:
the data residual between predicted probability and the true-class indicator, weighted by features[2]. The objective is convex (the Hessian is again of the form with positive semidefinite block matrix), so any local minimum is global.
Multi-class metrics shift
Predictions are now distributions over classes. The classification choice is . Probability quality (calibration, log-loss) becomes the right metric when downstream code consumes probabilities; classification accuracy alone hides miscalibration.
Check it on arrays
import numpy as np
rng = np.random.default_rng(0)
# Synthetic 3-class problem
n_per_class, d = 200, 3
X = np.vstack([
rng.normal(loc=[2, 0], size=(n_per_class, 2)),
rng.normal(loc=[-2, 0], size=(n_per_class, 2)),
rng.normal(loc=[0, 2], size=(n_per_class, 2)),
])
X = np.column_stack([np.ones(len(X)), X]) # intercept
y = np.array([0] * n_per_class + [1] * n_per_class + [2] * n_per_class)
K = 3
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True) # stable shift
e = np.exp(Z)
return e / e.sum(axis=1, keepdims=True)
def fit(X, y, K, lr=0.5, steps=2000, lam=0.0):
n, d = X.shape
Y = np.eye(K)[y] # one-hot
B = np.zeros((d, K))
for _ in range(steps):
P = softmax(X @ B)
grad = X.T @ (P - Y) / n + lam * B
B -= lr * grad
return B
B = fit(X, y, K, lr=1.0, steps=3000)
P = softmax(X @ B)
y_pred = P.argmax(axis=1)
acc = np.mean(y_pred == y)
log_loss = -np.mean(np.log(P[np.arange(len(y)), y] + 1e-12))
print(f"accuracy: {acc:.3f}")
print(f"log loss: {log_loss:.3f}")
# Sanity check: probabilities are valid
print(f"row sums (should be 1): {P.sum(axis=1)[:3]}")
print(f"all positive: {(P > 0).all()}")
# When K=2, softmax reduces to logistic on the difference of scores
B_binary = fit(X[:2 * n_per_class], y[:2 * n_per_class], K=2, lr=1.0)
diff = X[:2 * n_per_class] @ (B_binary[:, 1] - B_binary[:, 0])
p1 = 1 / (1 + np.exp(-diff))
print("softmax K=2 matches logistic:", np.allclose(p1, softmax(X[:2 * n_per_class] @ B_binary)[:, 1], atol=1e-6))The classifier gets the three Gaussian blobs apart, probabilities normalize per row, and the binary case reduces to logistic on the score difference.
Exercises
Exercise 1. Compute the softmax of the score vector over three classes by hand, then give the cross-entropy loss in nats if the true class is class 0.
Solution
Step 1. Exponentiate each score: , , . Their sum is .
Step 2. Divide each by the sum to normalize:
The entries are nonnegative and sum to 1, as a probability vector must.
Step 3. The cross-entropy loss for true class 0 is :
Exercise 2. Prove the shift invariance of the softmax: adding the same constant to every score leaves the output probabilities unchanged. This is why only differences between scores carry information.
Solution
Step 1. Write the softmax of the shifted vector , where every entry increases by . Component is
Step 2. Factor out of each exponential, using in both numerator and denominator:
Step 3. The common factor cancels, leaving
the softmax of the original . The output is invariant to a common additive shift, which is exactly the model's overparameterization (adding to every ) and the reason the stable softmax subtracts the maximum score before exponentiating.
Exercise 3 (code). Implement softmax cross-entropy and its gradient with respect to the scores . For one-hot labels the gradient is (predicted probabilities minus the one-hot targets). Verify this gradient against a finite-difference check on the total loss, and confirm the max difference is below .
Solution
import numpy as np
rng = np.random.default_rng(0)
n, K = 12, 4
Z = rng.normal(size=(n, K)) # raw scores
y = rng.integers(0, K, size=n)
Y = np.eye(K)[y] # one-hot targets
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True)
e = np.exp(Z)
return e / e.sum(axis=1, keepdims=True)
def loss(Z):
P = softmax(Z)
return -np.sum(Y * np.log(P + 1e-12))
P = softmax(Z)
grad_analytic = P - Y # d loss / d Z
# Finite-difference check on every entry of Z.
eps = 1e-6
grad_numeric = np.zeros_like(Z)
for i in range(n):
for k in range(K):
Zp = Z.copy(); Zp[i, k] += eps
Zm = Z.copy(); Zm[i, k] -= eps
grad_numeric[i, k] = (loss(Zp) - loss(Zm)) / (2 * eps)
max_diff = np.abs(grad_analytic - grad_numeric).max()
assert max_diff < 1e-6
print("max |analytic - numeric| gradient:", round(float(max_diff), 9))The analytic gradient matches finite differences to under . The data-residual form (predicted probability minus the one-hot indicator) is the multi-class echo of the logistic gradient , and is what the training loop multiplies by to update the coefficients.
The next lesson zooms out to the general framework that unifies linear, logistic, and Poisson regression: GLMs.
References
- [1]Bishop, C. M. (2006). Pattern Recognition and Machine Learning, §4.3.4. Springer. Softmax (multinomial logistic) regression.↩
- [2]Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning (2nd ed.), §4.4. Springer. doi:10.1007/978-0-387-84858-7. Multiclass logistic regression and its gradient.↩