AI Knowledge Tree

Softmax regression

Generalize logistic regression to multiple classes via the softmax and the cross-entropy loss.

Logistic regression handles two classes. For K>2K > 2 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 x∈Rdx \in \mathbb{R}^d and KK classes, the model has a coefficient vector βk∈Rd\beta_k \in \mathbb{R}^d per class. The class probabilities are

pk(x)=Pr⁡(y=k∣x)=exp⁡(x⊤βk)∑j=1Kexp⁡(x⊤βj).p_k(x) = \Pr(y = k \mid x) = \frac{\exp(x^\top \beta_k)}{\sum_{j=1}^K \exp(x^\top \beta_j)}.

The denominator normalizes the scores into a probability distribution (∑kpk=1\sum_k p_k = 1, every pk≥0p_k \ge 0). This is the softmax of the score vector z=(x⊤β1,…,x⊤βK)z = (x^\top \beta_1, \dots, x^\top \beta_K)[1].

The model is overparameterized: adding a constant cc to every βk\beta_k leaves the probabilities unchanged. A common fix is to set βK=0\beta_K = 0 (reduces to logistic regression when K=2K = 2); production code keeps all KK parameter vectors and lets regularization tie down the redundancy.

Cross-entropy loss

With one-hot true labels yi∈{e1,…,eK}\mathbf{y}_i \in \{e_1, \dots, e_K\}, the negative log-likelihood per example is the cross-entropy:

−log⁡pyi(xi)=−log⁡exp⁡(xi⊤βyi)∑jexp⁡(xi⊤βj)=log⁡ ⁣∑j ⁣exp⁡(xi⊤βj)−xi⊤βyi.-\log p_{y_i}(x_i) = -\log\frac{\exp(x_i^\top \beta_{y_i})}{\sum_j \exp(x_i^\top \beta_j)} = \log\!\sum_j\!\exp(x_i^\top \beta_j) - x_i^\top \beta_{y_i}.

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 −log⁡p0-\log p_0):

  • Confident and right. Scores (4,1,0)(4, 1, 0) softmax to (0.936,0.047,0.017)(0.936, 0.047, 0.017). The model puts 94%94\% on the true class, paying loss −log⁡0.936=0.066-\log 0.936 = 0.066 nats.
  • Hesitant. Scores (1,0.5,0)(1, 0.5, 0) give (0.506,0.307,0.186)(0.506, 0.307, 0.186): a bare majority on the true class, loss −log⁡0.506=0.68-\log 0.506 = 0.68 nats, ten times the confident case.
  • No opinion. Scores (0,0,0)(0, 0, 0) give the uniform (0.333,0.333,0.333)(0.333, 0.333, 0.333), loss −log⁡13=1.099-\log \tfrac13 = 1.099 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 1010 to every score, (14,11,10)(14, 11, 10), leaves the softmax at (0.936,0.047,0.017)(0.936, 0.047, 0.017) unchanged, since the shared constant cancels in the ratio. Only the differences between scores carry information.

Gradient

The gradient with respect to βk\beta_k has a clean form:

∇βk L=∑i(pk(xi)−1[yi=k]) xi,\nabla_{\beta_k}\, L = \sum_i (p_k(x_i) - \mathbf{1}[y_i = k])\, x_i,

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 X⊤WXX^\top W X with WW positive semidefinite block matrix), so any local minimum is global.

Multi-class metrics shift

Predictions are now distributions over KK classes. The classification choice is arg⁡max⁡kpk\arg\max_k p_k. Probability quality (calibration, log-loss) becomes the right metric when downstream code consumes probabilities; classification accuracy alone hides miscalibration.

Softmax regression carves the input space into one decision region per class, separated by linear boundaries.
Softmax regression carves the input space into one decision region per class, separated by linear boundaries.

Check it on arrays

python · numpy
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 z=(2,1,0)z = (2, 1, 0) 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: e2≈7.389e^2 \approx 7.389, e1≈2.718e^1 \approx 2.718, e0=1e^0 = 1. Their sum is 7.389+2.718+1=11.1077.389 + 2.718 + 1 = 11.107.

Step 2. Divide each by the sum to normalize:

p=(7.38911.107, 2.71811.107, 111.107)≈(0.665, 0.245, 0.090).p = \left(\frac{7.389}{11.107},\ \frac{2.718}{11.107},\ \frac{1}{11.107}\right) \approx (0.665,\ 0.245,\ 0.090).

The entries are nonnegative and sum to 1, as a probability vector must.

Step 3. The cross-entropy loss for true class 0 is −log⁡p0-\log p_0:

−log⁡0.665≈0.408 nats.-\log 0.665 \approx 0.408 \text{ nats}.

Exercise 2. Prove the shift invariance of the softmax: adding the same constant cc 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 z+c1z + c\mathbf{1}, where every entry increases by cc. Component kk is

exp⁡(zk+c)∑jexp⁡(zj+c).\frac{\exp(z_k + c)}{\sum_j \exp(z_j + c)}.

Step 2. Factor exp⁡(c)\exp(c) out of each exponential, using exp⁡(zk+c)=exp⁡(zk)exp⁡(c)\exp(z_k + c) = \exp(z_k)\exp(c) in both numerator and denominator:

exp⁡(zk)exp⁡(c)∑jexp⁡(zj)exp⁡(c)=exp⁡(c) exp⁡(zk)exp⁡(c)∑jexp⁡(zj).\frac{\exp(z_k)\exp(c)}{\sum_j \exp(z_j)\exp(c)} = \frac{\exp(c)\,\exp(z_k)}{\exp(c)\sum_j \exp(z_j)}.

Step 3. The common factor exp⁡(c)\exp(c) cancels, leaving

exp⁡(zk)∑jexp⁡(zj),\frac{\exp(z_k)}{\sum_j \exp(z_j)},

the softmax of the original zz. The output is invariant to a common additive shift, which is exactly the model's overparameterization (adding cc to every βk\beta_k) 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 ZZ. For one-hot labels the gradient is P−YP - Y (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 10−610^{-6}.

Solution
python · numpy
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 P−YP - Y matches finite differences to under 10−610^{-6}. The data-residual form (predicted probability minus the one-hot indicator) is the multi-class echo of the logistic gradient y−p\mathbf{y} - \mathbf{p}, and is what the training loop multiplies by X⊤X^\top to update the coefficients.

The next lesson zooms out to the general framework that unifies linear, logistic, and Poisson regression: GLMs.


References

  1. [1]Bishop, C. M. (2006). Pattern Recognition and Machine Learning, §4.3.4. Springer. Softmax (multinomial logistic) regression.↩
  2. [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.↩