Algorithms From Scratch

How To Implement Logistic Regression

Suresh Madhra·2025·14 min read

Logistic regression is the go-to linear classification algorithm for two-class problems. It is easy to implement, easy to understand and gets great results on a wide variety of problems — even when the assumptions of the method are violated.

In this tutorial you will discover how to implement logistic regression with stochastic gradient descent from scratch with Python — and, right here in your browser, run every snippet interactively.

Description

Logistic regression

Logistic regression is named for the logistic function at the core of the method. It uses a linear combination of the inputs, squashed by the sigmoid, to model the probability of the positive class:

Stochastic gradient descent

Gradient descent is an iterative optimiser: at each step, evaluate the slope of the loss with respect to every parameter and nudge each parameter a small amount in the opposite direction of that slope. Repeat until the loss stops falling. Batch gradient descent averages the gradient over the entire training set before each update; stochastic gradient descent uses the gradient from a single example, so it takes many more, noisier steps per epoch — but each step is cheap and, in practice, the noise helps the optimiser escape flat regions and shallow local minima.

For a single training example the log-loss gradient of logistic regression works out to a neat closed form. Combined with the learning rate η it gives the per-step update rule:

The picture below shows the same idea on a one-dimensional convex loss. Starting from a poor guess, each iteration takes a step proportional to the local slope — long strides where the surface is steep, tiny ones as the parameter approaches the minimum where the gradient vanishes.

-20234680510152025coefficient bloss L(b)minimumstart
Gradient descent on L(b) = (b − 3)² + 1 with η = 0.35. Each step moves b opposite to the gradient, so the size of the jump shrinks as the slope flattens near the minimum.

Two knobs govern the behaviour. The learning rate η scales every step: too small and training crawls; too large and the updates overshoot the minimum and can diverge. The number of epochs sets how many full passes we make through the training data — each pass gives every example one chance to nudge the coefficients.

1. Making predictions

The first step is a predict() function that takes a row and a set of coefficients and returns a probability. The first coefficient is the intercept (b0).

Hit Run below — the first click loads the Python runtime in your browser (~5 MB), subsequent runs are instant. Edit the code and re-run to experiment.

predict.py

2. Estimating coefficients

Now we estimate the coefficients with SGD. Three nested loops: over epochs, over rows in the training set, and over coefficients. We track the sum of squared error each epoch so we can watch it fall.

coefficients_sgd.py

You should see the error drop from ~3.0 to under 0.03 by the last epoch, with the final coefficients printed at the bottom.

How the code works

The whole training loop is just a direct translation of the update rule above. Reading coefficients_sgd from the top:

  • coef = [0.0 for i in range(len(train[0]))] — one coefficient per input column plus one for the bias, all seeded at zero. With the sigmoid, a zero vector predicts 0.5 for every row, a neutral starting point.
  • for epoch in range(n_epoch) — the outer loop is one full pass over the training data. sum_error is reset each epoch so we can watch the loss trend downward.
  • yhat = predict(row, coef) then error = row[-1] - yhat — compute the current prediction and its signed residual against the true label row[-1].
  • coef[0] = coef[0] + l_rate * error * yhat * (1.0 - yhat) — the bias update. The yhat * (1 - yhat) factor is the derivative of the sigmoid; it damps the update near confident predictions (where the sigmoid saturates) and amplifies it near 0.5 where the model is most uncertain.
  • coef[i + 1] = coef[i + 1] + l_rate * error * yhat * (1.0 - yhat) * row[i] — the weight update for each input, identical to the bias update but scaled by the input value x_j. Large inputs produce larger corrections, which is exactly why the wine-quality and diabetes examples normalise every column to 0–1 first.

Notice there is no matrix algebra, no autograd, and no library call — the entire optimiser is a dozen lines of arithmetic that faithfully implement the equation shown above.

3. Diabetes prediction

Finally we apply the algorithm to the Pima Indians diabetes dataset — 768 rows, 8 features, one binary label. The runner below fetches the CSV over the network from GitHub, normalises the features, and evaluates the model with 5-fold cross-validation. Training takes ~10–20 seconds in the browser.

diabetes.py

Expect a mean accuracy around 77% — well above the 65% Zero-Rule baseline.

Watching the coefficients learn

Rather than only printing a final accuracy, we can log sum_error and the full coef vector at the end of every epoch and plot them. The instrumented loop is a two-line change to coefficients_sgd:

history = []
for epoch in range(n_epoch):
    sum_error = 0.0
    for row in train:
        yhat = predict(row, coef)
        error = row[-1] - yhat
        sum_error += error ** 2
        coef[0] += l_rate * error * yhat * (1.0 - yhat)
        for i in range(len(row) - 1):
            coef[i + 1] += l_rate * error * yhat * (1.0 - yhat) * row[i]
    history.append((epoch, sum_error, list(coef)))   # <- record trajectory

Running that on 600 training rows of the diabetes dataset with η = 0.1 for 100 epochs produces the two curves below. The top chart is the sum-of-squared-error per epoch — a smooth, monotonic descent that flattens as the model approaches its best fit. The bottom chart tracks all nine coefficients as they migrate away from zero.

Sum-of-squared-error per epoch
Coefficient values across epochs
Pre-computed trajectory from coefficients_sgd on the Pima Indians diabetes dataset (600 training rows, η = 0.1, 100 epochs). Loss falls monotonically from ≈139 to ≈96 while the nine coefficients drift from zero toward their final values — glucose and BMI end up the strongest positive drivers, blood pressure and skin thickness the weakest.

A few things pop out. Glucose and BMI climb fastest and finish with the largest positive weights — high values push the sigmoid toward a "diabetic" prediction, which matches clinical intuition. The bias drifts strongly negative to offset that, keeping the baseline probability low for an average patient. Blood pressure and skin thickness barely move: after normalisation their signal is weak, so the gradient rarely pushes them. This is exactly what "the model is learning" looks like at the level of individual parameters.

Extensions

  • Tune the learning rate, epochs, or data preparation.
  • Switch stochastic updates for batch updates accumulated across an epoch.
  • Apply the technique to other binary classification problems from the UCI ML repository.