Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Warning! Although it is called logistic regression, logistic regression is actually a classification method.

By Ariane Ducellier (2021)

🖥️ Lecture slides — Session 16 (Wed Nov 4)

In this lab, we are going to talk about:

  • A simple classification method: Logistic regression
  • Gradient descent method
  • Automatic differentiation
  • Introduction to PyTorch

Logistic regression

Remember linear regression:

y=b+xw+ϵy = b + x w + \epsilon

yy is a vector of length nn, xx is a matrix with nn rows and pp columns, corresponding to nn observations and pp features that are used to explain yy.

bb is a scalar. ww is a vector of length pp. ε is a random error vector, of length nn. It is independent of xx, and has mean of zero.

Our objective is to find the best values of bb and ww so that the values of y^=b+xw\hat{y} = b + x w are as close as possible to the actual values yy.

For linear regression, yy is a quantitative variable. What if yy is a qualitative variable, for example y=0y = 0 for “no”, and y=1y = 1 for “yes”?

One way to use regression to solve a classification problem is to model the probability of the variable yy taking the value 1:

P(y=1)=b+xwP (y = 1) = b + x w

Once we have found the best values b^\hat{b} and w^\hat{w}, we compute y^=b^+xw^\hat{y} = \hat{b} + x \hat{w}. If y^0.5\hat{y} \geq 0.5, we decide to classify this observation as y=1y = 1, that is “yes”. If y^<0.5\hat{y} < 0.5, we decide to classify this observation as y=0y = 0, that is “no”.

There is a problem with this method. We would like to have 0P(y=1)10 \leq P (y = 1) \leq 1 because it is a probability. However, there is nothing in this formulation that forces bb and ww to take values such that y^=b^+xw^\hat{y} = \hat{b} + x \hat{w} will always take values in [0,1][0, 1].

To solve this problem, we can instead write:

z=b+xwz = b + x w and P(y=1)=11+ezP (y = 1) = \frac{1}{1 + e^{-z}}

That way, we always have 0P(y=1)10 \leq P (y = 1) \leq 1. When b+xwb + x w gets large, P(y=1)P (y = 1) gets close to 1, and the value “yes” is more and more likely. When b+xwb + x w gets small, P(y=1)P (y = 1) gets close to 0, and the value “no” is more and more likely.

How do we find the optimal value of bb and ww? We define the cross-entropy loss function. For one observation, the loss function is:

L=(yilogy^i+(1yi)log(1y^i))\mathcal{L} = - \left(y_i \log \hat{y}_i + (1 - y_i) \log (1 - \hat{y}_i)\right) with y^i=11+e(b+xiTw)\hat{y}_i = \frac{1}{1 + e^{- (b + x_i^T w)}} where xix_i is the iith row of x.

If the true observation yiy_i is 1 (“yes”) and y^i=1\hat{y}_i = 1, the loss function takes the value 0. If y^i=0\hat{y}_i = 0, the loss function tends to infinity.

If the true observation yy is 0 (“no”) and y^i=0\hat{y}_i = 0, the loss function takes the value 0. If y^i=1\hat{y}_i = 1, the loss function tends to infinity.

For all the nn observations, we write:

L=i=1nLi\mathcal{L} = \sum_{i = 1}^n \mathcal{L}_i

Our objective is thus to find the values of bb and ww that minimize the loss function. Note that with this formulation, L\mathcal{L} is always positive.

Gradient descent

We know that the gradient Lwj\frac{\partial \mathcal{L}}{\partial w_j} is positive if the loss L\mathcal{L} increases when wjw_j increases. Reversely, the gradient Lwj\frac{\partial \mathcal{L}}{\partial w_j} is negative if the loss L\mathcal{L} decreases when wjw_j increases.

To obtain smaller and smaller values of the loss, at each iteration we take:

wj(k+1)=wj(k)αLwjw_j^{(k + 1)} = w_j^{(k)} - \alpha \frac{\partial \mathcal{L}}{\partial w_j} for j=1,,pj = 1 , \cdots , p

b(k+1)=b(k)αLbb^{(k + 1)} = b^{(k)} - \alpha \frac{\partial \mathcal{L}}{\partial b}

We assume that the value of α is not too big. If the gradient is positive, then the value of wjw_j will decrease at each iteration, and the value of the loss function will decrease. If the gradient is negative, then the value of wjw_j will increase at each iteration, and the value of the loss will decrease.

So now, all we need to do is to compute the gradient of the loss function.

Automatic differentiation

There are three ways of computing the gradient. The first method is to use the formula of the loss:

L(wj,b)=i=1nyilog(11+exp(bj=1pwjxi,j))+(1yi)log(111+exp(bj=1pwjxi,j))\mathcal{L} (w_j , b) = - \sum_{i = 1}^n y_i \log (\frac{1}{1 + \exp (- b - \sum_{j = 1}^p w_j x_{i,j})}) + (1 - y_i) \log (1 - \frac{1}{1 + \exp (- b - \sum_{j = 1}^p w_j x_{i,j})})

and to calculate the exact formula of the derivatives Lwj\frac{\partial \mathcal{L}}{\partial w_j} and Lb\frac{\partial \mathcal{L}}{\partial b}. You just then have to implement the exact formula in the code to compute the gradient.

When the formula gets more and more complicated, you become more and more likely to make a mistake, either in the calculation of the derivative formula, either in the implementation in your code.

The second method is to compute an approximation of the gradient:

Lwj=L(wj+Δwj)L(wj)Δwj\frac{\partial \mathcal{L}}{\partial w_j} = \frac{\mathcal{L}(w_j + \Delta w_j) - \mathcal{L}(w_j)}{\Delta w_j}

If you write too many approximations, the method may not work very well and give inexact results.

The third method is to use automatic differentiation. If we write:

z=xiTw+b=fx(w,b)z = x_i^T w + b = f_x(w, b), σ=11+ez=g(z)\sigma = \frac{1}{1 + e^{-z}} = g(z) and L=(yilog(σ)+(1yi)log(1σ))=hy(σ)L = - (y_i \log(\sigma) + (1 - y_i) \log(1 - \sigma)) = h_y(\sigma), we get:

Lwj=fwjg(z)h(σ)\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(z) h'(\sigma)

It is very easy to compute the exact formula of the derivatives:

fwj(w,b)=xi,j\frac{\partial f}{\partial w_j}(w, b) = x_{i,j}

g(z)=ez(1+ez)2g'(z) = \frac{e^{-z}}{(1 + e^{-z})^2}

h(σ)=yiσ+1yi1σh'(\sigma) = - \frac{y_i}{\sigma} + \frac{1 - y_i}{1 - \sigma}

When computing LL, we thus need to keep in memory the values of fwj(w,b)\frac{\partial f}{\partial w_j}(w, b), g(z)g'(z), and h(σ)h'(\sigma) to be able to compute the gradient. That is what PyTorch is doing.

Introduction to PyTorch

PyTorch (https://pytorch.org/) is a Python package which allows you to build and train neural networks. It is based on automatic differentiation.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pooch
import torch
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

The data: water potability

We use a table of water-quality measurements with a binary target, Potability (0 = not potable, 1 = potable). Each row has 9 numeric features (pH, hardness, dissolved solids, and so on).

path = pooch.retrieve(
    url="https://raw.githubusercontent.com/UW-MLGEO/MLGeo-dataset/main/data/water_potability.csv",
    known_hash=None,
    fname="water_potability.csv",
    path=pooch.os_cache("mlgeo"),
)
data = pd.read_csv(path)
Downloading data from 'https://raw.githubusercontent.com/UW-MLGEO/MLGeo-dataset/main/data/water_potability.csv' to file '/home/runner/.cache/mlgeo/water_potability.csv'.
SHA256 hash of downloaded file: 111a9ba65f2d791003ac07b54b8e18e2f7e0bc1cfa37cfdb251d3758a4e8c24c
Use this value as the 'known_hash' argument of 'pooch.retrieve' to ensure that the file hasn't changed if it is downloaded again in the future.

Some rows have missing values. We drop them and reset the row index.

data = data.dropna()
data = data.reset_index(drop=True)
data.head()
Loading...
x = data.drop(columns=['Potability']).to_numpy()
y = data.Potability.to_numpy()
print(x.shape, y.shape)
(2011, 9) (2011,)

Split before you scale

We set aside 30% of the rows as a test set before doing anything else. We fit the scaler on the training rows only and apply it to both sets: if the scaler saw the test rows, information about the test set would leak into training, and the final evaluation would no longer be honest.

x_train, x_test, y_train_np, y_test_np = train_test_split(
    x, y, test_size=0.3, random_state=42, stratify=y)

scaler = StandardScaler().fit(x_train)
x_train = scaler.transform(x_train)
x_test = scaler.transform(x_test)
print(x_train.shape, x_test.shape)
(1407, 9) (604, 9)

Before training anything, compute the trivial baseline: a “model” that always predicts the most common class. Any classifier worth keeping must beat this number.

baseline = max(np.mean(y_train_np == 0), np.mean(y_train_np == 1))
print(f"Majority-class baseline accuracy: {baseline:.3f}")
Majority-class baseline accuracy: 0.597

One observation, by hand

We are going to compute the loss corresponding to the first observation in the training set. Instead of using Numpy arrays to put our data and parameters, we are going to use torch tensors, because they have properties that Numpy arrays do not have.

This is the features of the first training observation:

x_i = torch.from_numpy(x_train[0, :])
x_i = x_i.float()
x_i
tensor([-0.0951, 0.0145, 2.0111, 0.8662, -0.3787, 0.7364, 0.1168, -1.5465, 0.6595])

This is the class of the first training observation:

y_i = float(y_train_np[0])
print(y_i)
1.0

Let us take random values for ww and bb. When creating these variables, we use the option requires_grad=True because we will later want to compute the gradient with respect to these variables.

W = torch.rand(9, requires_grad=True)
B = torch.rand(1, requires_grad=True)

Let us define z=f(w,b)=xiTw+bz = f(w, b) = x_i^T w + b. We have fwj=xi,j\frac{\partial f}{\partial w_j} = x_{i,j} and fb=1\frac{\partial f}{\partial b} = 1.

By default, PyTorch only keeps gradients for leaf tensors like W and B. Intermediate tensors such as zz do not keep their gradients; calling retain_grad() asks PyTorch to store them so we can inspect them after the backward pass.

z = W.dot(x_i) + B
z.retain_grad()
z
tensor([0.2912], grad_fn=<AddBackward0>)

Let us define σ=g(z)=11+ez=g(f(w,b))=(gf)(w,b)\sigma = g(z) = \frac{1}{1 + e^{-z}} = g(f(w, b)) = (g \circ f) (w, b). We have g(z)=ez(1+ez)2g'(z) = \frac{e^{-z}}{(1 + e^{-z})^2}.

sigma = 1.0 / (1.0 + torch.exp(- z))
sigma.retain_grad()
sigma
tensor([0.5723], grad_fn=<MulBackward0>)

Note that here we use the function torch.exp instead of numpy.exp. That is because numpy just calculate the value of exe^x but does not know that the derivative of exe^x is exe^x. If we want to be able to use automatic differentiation, we need to use the equivalent torch function that will compute both σ(z)\sigma(z) and σz(z)\frac{\partial \sigma}{\partial z}(z). This last value will be necessary and we will later compute the gradient.

Let us define L=h(σ)=(yilog(σ)+(1yi)log(1σ))=h(g(z))=h(g(f(w,b)))=(hgf)(w,b)L = h(\sigma) = - (y_i \log(\sigma) + (1 - y_i) \log(1 - \sigma)) = h(g(z)) = h(g(f(w, b))) = (h \circ g \circ f) (w, b). We have L(σ)=(yiσ1yi1σ)L'(\sigma) = - (\frac{y_i}{\sigma} - \frac{1 - y_i}{1 - \sigma}).

L = - (y_i * torch.log(sigma) + (1 - y_i) * torch.log(1 - sigma))

We can now compute the gradient of the loss for one observation. This command computes the gradient of L with respect to all the variables that keep a gradient, that is W, B, z, and sigma, but it does not return the value.

L.backward()

We have Lσ=(yiσ1yi1σ)\frac{\partial L}{\partial \sigma} = - (\frac{y_i}{\sigma} - \frac{1 - y_i}{1 - \sigma}). Let us compare the result from PyTorch with the exact mathematical formula.

h_prime = - (y_i / sigma - (1 - y_i) / (1 - sigma))
print(sigma.grad.item(), h_prime.item())
-1.7473633289337158 -1.7473633289337158

We have Lz=g(z)h(g(z))\frac{\partial L}{\partial z} = g'(z) h'(g(z)) that is Lz=g(z)h(σ)\frac{\partial L}{\partial z} = g'(z) h'(\sigma). Let us compare the result from PyTorch with the exact mathematical formula.

g_prime = torch.exp(- z) / ((1 + torch.exp(- z)) ** 2.0)
print(z.grad.item(), (g_prime * h_prime).item())
-0.4277091920375824 -0.42770916223526

We have Lb=fbg(f(w,b))h(g(f(w,b)))\frac{\partial L}{\partial b} = \frac{\partial f}{\partial b} g'(f(w, b)) h'(g(f(w, b))) that is Lb=fbg(z)h(σ)\frac{\partial L}{\partial b} = \frac{\partial f}{\partial b} g'(z) h'(\sigma). Similarly, we have Lwj=fwjg(f(w,b))h(g(f(w,b)))\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(f(w, b)) h'(g(f(w, b))) that is Lwj=fwjg(z)h(σ)\frac{\partial L}{\partial w_j} = \frac{\partial f}{\partial w_j} g'(z) h'(\sigma). Let us compare the results from PyTorch with the exact mathematical formulas.

print(B.grad.item(), (1 * g_prime * h_prime).item())
-0.4277091920375824 -0.42770916223526
print(W.grad)
print((x_i * g_prime * h_prime).detach())
tensor([ 0.0407, -0.0062, -0.8602, -0.3705,  0.1620, -0.3150, -0.0500,  0.6614,
        -0.2821])
tensor([ 0.0407, -0.0062, -0.8602, -0.3705,  0.1620, -0.3150, -0.0500,  0.6614,
        -0.2821])

Implementation of logistic regression

Let us now implement logistic regression using the whole training set.

X = torch.from_numpy(x_train).float()
Y = torch.from_numpy(y_train_np).float()

We could code the update rule w(k+1)=w(k)αL/ww^{(k+1)} = w^{(k)} - \alpha \, \partial \mathcal{L} / \partial w by hand, and earlier editions of this book did, with retain_grad bookkeeping at every step. torch.optim.SGD does exactly that update rule, so we let it handle the parameter updates and keep the loss formula explicit. We write the binary cross-entropy out in full rather than calling torch.nn.BCELoss — the point here is transparency, not convenience.

Two practical details:

  • We clamp σ away from 0 and 1 before taking logs, so the loss never returns log(0).
  • We stop when the relative change in the loss drops below 10-6, or after 2000 iterations.
p = X.size()[1]
W = torch.zeros(p, requires_grad=True)
B = torch.zeros(1, requires_grad=True)
optimizer = torch.optim.SGD([W, B], lr=0.1)

max_iter = 2000
losses = []
for i in range(max_iter):
    optimizer.zero_grad()
    z = X @ W + B
    sigma = torch.sigmoid(z)
    sigma = torch.clamp(sigma, 1e-7, 1 - 1e-7)
    L = - (Y * torch.log(sigma) + (1 - Y) * torch.log(1 - sigma)).mean()
    L.backward()
    optimizer.step()
    losses.append(L.item())
    if i > 0 and abs(losses[-1] - losses[-2]) / abs(losses[-2]) < 1e-6:
        break

print(f"Stopped after {len(losses)} iterations, final training loss: {losses[-1]:.4f}")
Stopped after 154 iterations, final training loss: 0.6707

Plot the loss curve. It should decrease quickly at first, then flatten.

plt.plot(losses)
plt.xlabel('Iteration')
plt.ylabel('Mean cross-entropy loss')
plt.title('Gradient descent on the training set')
plt.grid(alpha=0.3)
plt.show()
<Figure size 640x480 with 1 Axes>

Evaluation on the test set

We now predict on both the training set and the test set. Earlier editions of this lesson evaluated the model on the same data it was trained on; the gap between the two numbers below is exactly why that overstates skill.

X_test_t = torch.from_numpy(x_test).float()
with torch.no_grad():
    proba_train = torch.sigmoid(X @ W + B).numpy()
    proba_test = torch.sigmoid(X_test_t @ W + B).numpy()

yhat_train = np.where(proba_train > 0.5, 1, 0)
yhat_test = np.where(proba_test > 0.5, 1, 0)

Let us now compute some classification metrics by hand, on the test set. N_test is the number of test observations.

y_test = y_test_np
N_test = len(y_test)
# True positive
tp = np.sum((y_test == 1) & (yhat_test == 1))
print(tp / N_test)
0.006622516556291391
# False negative
fn = np.sum((y_test == 1) & (yhat_test == 0))
print(fn / N_test)
0.3973509933774834
# False positive
fp = np.sum((y_test == 0) & (yhat_test == 1))
print(fp / N_test)
0.004966887417218543
# True negative
tn = np.sum((y_test == 0) & (yhat_test == 0))
print(tn / N_test)
0.5910596026490066
# Accuracy (percentage of correct classifications)
accuracy = (tp + tn) / (tp + tn + fp + fn)
print(accuracy)
0.597682119205298
# Recall (= sensitivity = percentage of positive values correctly classified)
recall = tp / (tp + fn)
print(recall)
0.01639344262295082
# Precision (= percentage of positive predictions that were correct)
precision = tp / (tp + fp)
print(precision)
0.5714285714285714
# F1
F1 = (2 * precision * recall) / (precision + recall)
print(F1)
0.03187250996015936

Now compare training accuracy against test accuracy, next to the majority-class baseline.

train_accuracy = np.mean(yhat_train == y_train_np)
test_accuracy = np.mean(yhat_test == y_test)
print(f"Baseline (majority class): {baseline:.3f}")
print(f"Train accuracy:            {train_accuracy:.3f}")
print(f"Test accuracy:             {test_accuracy:.3f}")
Baseline (majority class): 0.597
Train accuracy:            0.606
Test accuracy:             0.598

Logistic regression barely beats the majority-class baseline on this table, and the recall on the potable class is low. That is an honest result: these 9 features, combined linearly, carry little signal about the target. A near-null result reported against a baseline is more useful than an inflated training-set score. If someone reports only a training accuracy, ask what the baseline was and what a held-out test set says.

Are the probabilities honest?

Everything above thresholds the sigmoid output at 0.5 and scores the resulting labels. But the model outputs a probability, and in many applications the probability itself is what you hand over. A classifier is calibrated when its stated probabilities match observed frequencies: among all test samples where the model says “70% chance potable”, about 70% should be potable.

Two tools measure this on the test set:

  • A reliability diagram (sklearn.calibration.calibration_curve) bins the samples by predicted probability and plots the observed fraction of positives in each bin against the mean predicted probability. A calibrated model tracks the diagonal.
  • The Brier score is the mean squared error of the probability, 1Ni=1N(y^iyi)2\frac{1}{N} \sum_{i=1}^N (\hat{y}_i - y_i)^2. Lower is better. Unlike accuracy, which only sees which side of 0.5 a prediction falls on, the Brier score punishes a wrong 0.99 much harder than a wrong 0.55.
from sklearn.calibration import calibration_curve
from sklearn.metrics import brier_score_loss

# Brier score by hand: the mean squared error of the probability
brier_logistic = np.mean((proba_test - y_test) ** 2)

frac_pos, mean_pred = calibration_curve(
    y_test, proba_test, n_bins=10, strategy='quantile')

plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], 'k--', label='perfect calibration')
plt.plot(mean_pred, frac_pos, 'o-', label='logistic regression')
plt.xlabel('Mean predicted probability of potable')
plt.ylabel('Observed fraction potable')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"Logistic regression  Brier: {brier_logistic:.3f}  "
      f"test accuracy: {test_accuracy:.3f}")
<Figure size 500x500 with 1 Axes>
Logistic regression  Brier: 0.243  test accuracy: 0.598

The model’s probabilities span only about 0.27 to 0.57: it never claims certainty about any sample, which is consistent with the weak signal in these features. The curve wobbles around the diagonal, with most of the middle bins sitting below it — where the model says 40% potable, the observed frequency is closer to 30–40%, a mild overconfidence toward the potable class. Each quantile bin holds about 60 test samples, so each observed fraction carries sampling noise of roughly ±0.06, and only departures larger than that deserve interpretation. Verdict: roughly honest, weakly informative. The probabilities are usable, but they mostly restate the base rate.

A confident competitor, and how to repair it

A predict_proba method is no guarantee that the numbers coming out of it deserve to be called probabilities. Take a small random forest: 10 deep trees, each of which nearly memorizes the training set. Its “probability” of potable is the fraction of trees voting potable, and with 10 overfit trees those fractions easily land on extreme values like 0.9 or 1.0.

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=10, random_state=0)
rf.fit(x_train, y_train_np)
proba_rf = rf.predict_proba(x_test)[:, 1]

brier_rf = brier_score_loss(y_test, proba_rf)
acc_rf = np.mean((proba_rf > 0.5) == y_test)
print(f"Random forest  train accuracy: {rf.score(x_train, y_train_np):.3f}")
print(f"Random forest  test accuracy:  {acc_rf:.3f}   Brier: {brier_rf:.3f}")
Random forest  train accuracy: 0.974
Random forest  test accuracy:  0.619   Brier: 0.239

The forest memorizes the training set and still beats the logistic model on test accuracy. Now check whether its probabilities can be trusted, and repair them if not. The stakes are practical: when a model tells a hazard office “80% chance”, about 80% of those cases should come true, or the number is not a forecast anyone can act on. The repair re-labels the model’s scores using observed frequencies: hold some data samples out, record how often the samples scored near 0.9 actually turn out potable, and correct each score to that observed frequency. Only order-preserving corrections are allowed — if the forest scored sample A above sample B, the corrected probability of A stays at or above that of B — so the ranking of the samples survives; only the numbers attached to it change. CalibratedClassifierCV supplies the held-out samples by refitting the forest on cross-validation folds (the rotating splits of lesson 3.8), and scikit-learn’s name for the order-preserving fit is isotonic regression (method='isotonic').

from sklearn.calibration import CalibratedClassifierCV

cal_rf = CalibratedClassifierCV(
    RandomForestClassifier(n_estimators=10, random_state=0),
    method='isotonic', cv=5)
cal_rf.fit(x_train, y_train_np)
proba_cal = cal_rf.predict_proba(x_test)[:, 1]

brier_cal = brier_score_loss(y_test, proba_cal)
acc_cal = np.mean((proba_cal > 0.5) == y_test)

plt.figure(figsize=(5, 5))
plt.plot([0, 1], [0, 1], 'k--', label='perfect calibration')
for proba, brier, label in [
        (proba_rf, brier_rf, 'raw forest'),
        (proba_cal, brier_cal, 'isotonic-calibrated forest')]:
    frac_pos, mean_pred = calibration_curve(y_test, proba, n_bins=10)
    plt.plot(mean_pred, frac_pos, 'o-', label=f'{label} (Brier {brier:.3f})')
plt.xlabel('Mean predicted probability of potable')
plt.ylabel('Observed fraction potable')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"Raw forest         Brier: {brier_rf:.3f}   accuracy: {acc_rf:.3f}")
print(f"Calibrated forest  Brier: {brier_cal:.3f}   accuracy: {acc_cal:.3f}")
<Figure size 500x500 with 1 Axes>
Raw forest         Brier: 0.239   accuracy: 0.619
Calibrated forest  Brier: 0.219   accuracy: 0.671

The raw forest’s curve falls far below the diagonal on the right: where it claims an 80% or 90% chance of potable, the observed frequency is close to 50%. It lies confidently in exactly the range a user would act on, and its test accuracy gives no hint of it. Isotonic calibration lowers the Brier score and moves the curve toward the diagonal, though not onto it: the calibration map is estimated from about 1400 training rows and carries its own sampling noise. The accuracy also improves a little, partly because CalibratedClassifierCV averages the five forests it fits across folds — a small ensemble bonus, not the point of the exercise.

In risk settings the probability is the deliverable, not the label. A geotechnical engineer reports a probability of liquefaction that enters a building-code check; a water manager acts on a probability of a harmful algal bloom; a floodplain map encodes probabilities of flood exceedance. The person who sets the decision threshold — how much probability justifies the cost of acting — is usually not the person who trained the model, and they will take the number at face value. A miscalibrated 90% that means 55% silently moves your modeling error into someone else’s decision. Whenever a model’s probabilities feed a threshold you do not own, report a reliability diagram and a Brier score next to the accuracy. Ensemble disagreement gives a complementary view of uncertainty in lesson 3.9, and the same reliability check is applied to deep ensembles in lesson 4.5.

Appendix

Logistic regression is a nice example to start learning about automatic differentiation and PyTorch. However, if you actually want to use logistic regression for your own dataset, it is much easier to use the function already existing in scikit-learn. We follow the same protocol: fit on the training set, report on the test set.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_recall_fscore_support
model = LogisticRegression(random_state=0).fit(x_train, y_train_np)
model.coef_
array([[ 0.04513734, -0.02944814, 0.12027801, 0.05730839, -0.04010493, -0.07808411, 0.02579578, 0.00593486, 0.02137578]])
model.intercept_
array([-0.39579648])

Reading the coefficients

The features were standardized, so each coefficient is the change in the log-odds of potability for a one-standard-deviation increase in that feature, holding the others fixed. Exponentiating turns it into an odds ratio: a coefficient wjw_j multiplies the odds of potability by ewje^{w_j} per standard deviation of feature jj. The cell below reads off the largest coefficient this way.

Two cautions. First, the sign of a coefficient describes the fitted model, not water chemistry: with correlated features and a signal this weak, the fit can hand a chemically implausible sign to one feature while a correlated feature absorbs the opposite effect — the same caveat as feature importance in lesson 3.7. Second, sklearn’s LogisticRegression applies L2 regularization by default (C=1.0), so its coefficients are shrunk toward zero and differ from the unpenalized gradient-descent fit we trained above — only slightly here, because with about 2,000 training samples the default penalty is gentle, but the table below shows the two coefficient vectors side by side so you can check rather than assume.

feature_names = data.drop(columns=['Potability']).columns
coefs = pd.DataFrame(
    {"gradient descent (no penalty)": W.detach().numpy(),
     "sklearn (L2, C=1.0)": model.coef_[0]},
    index=feature_names)
print(coefs.round(3))

top = coefs["sklearn (L2, C=1.0)"].abs().idxmax()
w_top = coefs.loc[top, "sklearn (L2, C=1.0)"]
print(f"\nOne standard deviation of {top} multiplies the odds of potability "
      f"by exp({w_top:.3f}) = {np.exp(w_top):.3f}")
                 gradient descent (no penalty)  sklearn (L2, C=1.0)
ph                                       0.043                0.045
Hardness                                -0.028               -0.029
Solids                                   0.117                0.120
Chloramines                              0.055                0.057
Sulfate                                 -0.040               -0.040
Conductivity                            -0.076               -0.078
Organic_carbon                           0.024                0.026
Trihalomethanes                          0.006                0.006
Turbidity                                0.021                0.021

One standard deviation of Solids multiplies the odds of potability by exp(0.120) = 1.128
yhat_sk = model.predict(x_test)
metrics = precision_recall_fscore_support(y_test, yhat_sk, average='binary')
(precision_sk, recall_sk, F1_sk) = (metrics[0], metrics[1], metrics[2])
print(precision_sk, recall_sk, F1_sk)
0.5714285714285714 0.01639344262295082 0.03187250996015936
print(f"sklearn train accuracy: {model.score(x_train, y_train_np):.3f}")
print(f"sklearn test accuracy:  {model.score(x_test, y_test):.3f}")
sklearn train accuracy: 0.606
sklearn test accuracy:  0.598