Ch.24: Logistic Regression's Gradient Descent, Derived and Coded
Inspired by: YouTube
Ch.23 derived log loss but ended on the same wall Ridge and Lasso didn't hit: no closed-form solution. Log loss has no algebraic rearrangement that isolates the weights directly, so gradient descent is the only way to minimize it. This post differentiates that loss function with respect to the weight vector, turns the result into a weight-update rule, and codes it from scratch.
1. Setting Up in Matrix Form
Say the dataset has rows and feature columns. Stack the rows into a data matrix and prepend a column of all 1s so the intercept can be folded into the same weight vector as every other coefficient:
is , is . Every row's prediction, , is exactly the dot product of that row with , run through sigmoid. Stack all predictions and the whole thing collapses to one line:
2. Log Loss in Matrix Form
Ch.23's loss function summed a per-row term over all rows:
Stacking into a column vector and into , that sum is a dot product, pairs each with its matching and adds them all up in one shot. So the loss becomes:
Same formula as before, just without the explicit summation sign, the vector notation carries it implicitly.
3. Differentiating With Respect to W
To run gradient descent, is needed, a single vector holding all at once. Differentiate the two bracketed terms one at a time.
First term, . By the chain rule, differentiating needs , then needs sigmoid's own derivative , then the inner term differentiates to . Multiplying the chain together:
The in the numerator of sigmoid's derivative cancels the from the log derivative, leaving just .
Second term, . Same chain rule, but differentiating needs and needs (the negative sign comes along with differentiating the wrapper):
This time the terms cancel, leaving .
Adding the two terms:
The terms cancel each other out, leaving a strikingly simple result. Bringing back the from the full loss:
(Written as rather than to fix the shapes: is and is , so multiplies an matrix by an vector, producing the vector that matches 's shape.)
4. The Weight Update Rule
Gradient descent subtracts the learning rate times the gradient from the current weights:
The two minus signs cancel, so the update rule is a clean addition: new weights = old weights, plus the learning rate, times the average of over all rows. Every weight, through , gets updated by this one vector equation simultaneously, no looping over individual coefficients required.
5. Coding It
Translating that directly into NumPy, closely following the source notebook:
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def gd(X, y):
X = np.insert(X, 0, 1, axis=1)
weights = np.ones(X.shape[1])
lr = 0.5
for i in range(5000):
y_hat = sigmoid(np.dot(X, weights))
weights = weights + lr * (np.dot((y - y_hat), X) / X.shape[0])
return weights[1:], weights[0]np.insert(X, 0, 1, axis=1) prepends the all-1s intercept column derived in Section 1. weights starts at all 1s and has shape . Inside the loop, y_hat is from Section 1, and the update line is Section 4's rule verbatim: np.dot((y - y_hat), X) computes (NumPy's 1-D dot between a length- vector and an matrix already produces the result, no explicit transpose needed), dividing by X.shape[0] averages over rows, and lr is . weights[0] is the intercept , weights[1:] is everything else.
6. Checking It Against scikit-learn
Running this against the same dataset used throughout the Logistic Regression posts and comparing the resulting boundary to sklearn.linear_model.LogisticRegression:
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
X, y = make_classification(n_samples=100, n_features=2, n_informative=1, n_redundant=0,
n_classes=2, n_clusters_per_class=1, random_state=41,
hypercube=False, class_sep=20)
lor = LogisticRegression(penalty=None, solver="sag")
lor.fit(X, y)
coef_, intercept_ = gd(X, y)
At 5000 epochs and a learning rate of 0.5, the two lines are effectively on top of each other, sklearn finds , , and the from-scratch version lands at , . That's the direct payoff of deriving the update rule from an actual loss function instead of the ad-hoc push-and-pull rule from Ch.22: the two implementations now agree almost exactly, closing the gap that the sigmoid-perceptron couldn't.
With a much lower epoch count, or a small dataset like this one where a single sharp gap makes convergence sensitive to the exact optimizer, the two lines can still visibly diverge, which is expected: sklearn's sag solver isn't running plain full-batch gradient descent internally, it's a different (and more optimized) iterative method. Full-batch gradient descent over enough epochs converges toward the same optimum, but there's no guarantee the two paths there look identical at every step.
7. Where This Leaves the Series
That closes the arc that started back in Ch.20: starting from the perceptron trick's step function, swapping in sigmoid, deriving log loss from maximum likelihood, and now deriving and coding its gradient descent update rule, a Logistic Regression built entirely from scratch that matches scikit-learn's own implementation. The gd function above runs full-batch (every row, every update); the same loss function supports stochastic and mini-batch variants too, using the same batching ideas from Ch.8 through Ch.10, just applied to this loss instead of squared error.
