S086 · Logistic Regression — Sigmoid, Cross-Entropy, from Scratch
Turn linear regression into a classifier — sigmoid squashes to probability, cross-entropy replaces MSE, and gradient descent barely changes.
🎯 Derive and code logistic regression from scratch — understand why cross-entropy is the ‘right’ loss for classification, and calibrate probabilities properly.
Why this session exists
Logistic regression is the single most useful model in industry — banks use it to score credit, spam filters use it to score email, ad-tech uses it to score click-through, and every ML team on Earth uses it as the baseline they must beat before shipping anything fancier. Once you understand that it's just linear regression with a sigmoid squash and a cross-entropy loss, you also understand the output layer of every classification neural network ever written. This session builds it from y = wx + b up.
- Explain why the sigmoid is the correct squashing function for binary classification.
- Derive cross-entropy loss from maximum likelihood in three lines.
- Code logistic regression + gradient descent in ~60 lines of numpy.
- Choose a decision threshold from a precision-recall curve, not from the default 0.5.
- Diagnose calibration issues with a reliability plot.
Prerequisites
- S084 · ML mental model — X/y/split/loss/metric scaffold.
- S085 · Linear regression from scratch — you can code batch GD.
- S040 · Probability basics — you know what a Bernoulli distribution is.
(a) Intuition · 5 min
You want to answer a yes/no question — is this email spam? — but the underlying signal is a continuous ‘how spammy does this look?’ Linear regression gives you an unbounded number; ‘4.2 spam-units’ doesn't map to a probability.
A dimmer switch turns any input into brightness between 0 and 1. Zero input → half-bright. Big positive input → fully on. Big negative input → fully off. That's the sigmoid.
Logistic regression = linear regression whose output z = xᵀθ is squashed through the sigmoid σ(z) = 1/(1+e⁻ᶻ) to produce a probability in (0, 1). We train it to maximise the probability of the observed labels — the log of which is the negative of the cross-entropy loss.
Everything downstream — the gradient, the update rule, the diagnostics — is the same as linear regression, with one twist: the loss is now non-negative and shaped like a hill you can only see from above, but still convex.
The three ideas to own before we code
- Model: p = σ(Xθ), where σ(z) = 1 / (1 + e⁻ᶻ) — same linear combo, then squash.
- Loss: binary cross-entropy L = -Σ[y·log p + (1-y)·log(1-p)] — falls out of Bernoulli MLE.
- Fit: gradient descent, exactly as before. ∇L = (1/n)·Xᵀ(p - y) — beautifully symmetric with linear regression's Xᵀ(ŷ - y).
A brief history
- 1838Logistic function · VerhulstPierre-François Verhulst invents the S-curve to model population growth. Nothing to do with classification yet.
- 1944Berkson coins ‘logit’Joseph Berkson uses the sigmoid for bioassay. Log-odds get the name ‘logit.’
- 1958Cox popularises for statisticsDavid Cox brings logistic regression into applied statistics as an alternative to probit.
- 1990sStandard in ad tech + financeGoogle, Facebook, and every credit bureau standardise on logistic regression for scoring.
- 2016Wide & Deep · GoogleLogReg + neural nets in one model for Play Store recommendations. LogReg is not dead — it lives inside deep architectures.
(b) Visual walkthrough · 15 min
The sigmoid — the shape you will see forever
Values of σ:
- σ(0) = 0.5 (undecided)
- σ(2) ≈ 0.88 (fairly confident yes)
- σ(-2) ≈ 0.12 (fairly confident no)
- σ(±∞) → 1 / 0 (asymptotes never actually reached)
Cross-entropy — where it comes from
Deriving BCE from Maximum Likelihood
Metrics beyond accuracy — the whole zoo
% correct
- Fine for balanced datasets (spam/not spam ~50/50).
- USELESS for imbalanced (fraud, medical rare-disease).
- ‘Predict no fraud always’ gets 99.9 % on fraud data.
- Depends on the decision threshold.
- Never the only metric you should report.
For minority class
- Precision = TP / (TP + FP). ‘Of positives I predicted, how many were real?’
- Recall = TP / (TP + FN). ‘Of real positives, how many did I catch?’
- F1 = harmonic mean of P and R.
- Trade off P vs R by changing threshold.
- Reported per-class in multi-class.
Threshold-independent ranking
- AUC ≈ probability a random positive gets a higher score than a random negative.
- ROC-AUC: safe on balanced data.
- PR-AUC: preferred on heavy imbalance (Manning + Raghavan recommendation).
- Doesn't depend on threshold — compare models fairly.
- 0.5 = random, 1.0 = perfect, 0.7-0.9 = typical shippable.
Decision threshold — the knob nobody tunes
"Logistic regression outputs a probability, so if it says 0.8 for a customer, roughly 80% of customers like that will convert. And 0.5 is the natural place to cut for a yes/no decision."
Two separate errors. First, the output is only a calibrated probability if the model is calibrated — logistic regression trained with plain log-loss on a representative sample usually is, but resample the classes, apply strong regularisation, or train on a re-weighted set and the numbers shift systematically while ranking stays fine. Second, 0.5 is not a natural threshold; it is the threshold that minimises raw error rate under equal misclassification costs. Your costs are essentially never equal.
The myth is sticky because 0.5 is the point where the sigmoid crosses its midpoint and where the linear part is exactly zero — it looks like it is built into the model, a mathematical fact rather than a business choice. And it genuinely is optimal in the one situation textbooks use: balanced classes, symmetric costs. Meet that case first and the threshold stops looking like a decision at all. It is, and it belongs to the business, not the model.
Separate the model from the decision. Train once, then move the threshold and watch the operating point travel:
from sklearn.metrics import precision_recall_curve, roc_auc_score
p = model.predict_proba(X_val)[:, 1]
print(roc_auc_score(y_val, p)) # unchanged by threshold
prec, rec, thr = precision_recall_curve(y_val, p)
for t in (0.1, 0.3, 0.5, 0.7, 0.9):
yhat = (p >= t)
print(t, yhat.sum(), (yhat & (y_val == 1)).sum())
# AUC never moves. Precision and recall move enormously.
# The model is one object; the threshold is a different decision.Why the sigmoid specifically? Any squashing function maps the real line into (0,1) — arctan and the error function both do it. The choice of 1/(1+e⁻ᶻ) looks like one arbitrary S-curve among many. It is forced.
- 1A linear model produces an unbounded real number, but a probability must lie in [0,1]. So we need a bijection between the real line and that interval — and we should derive it rather than pick one.forced by · we want the linear structure to remain meaningful, not to be clipped or distorted arbitrarily
- 2Start from the quantity that is naturally unbounded on one side: the odds, p/(1−p), which ranges over (0, ∞) as p ranges over (0,1).forced by · odds is the ratio in which a rational bettor would accept the wager, and it removes the upper bound
- 3Take the logarithm and you get the log-odds (logit), which ranges over the entire real line — exactly the range a linear model produces. So the natural claim is that the linear combination models the log-odds:
log(p/(1−p)) = βᵀx.forced by · log maps (0, ∞) onto (−∞, ∞), matching the model's output domain exactly - 4Now invert that equation to recover p. Exponentiating gives
p/(1−p) = e^z, and solving for p givesp = e^z/(1+e^z) = 1/(1+e⁻ᶻ). The sigmoid is not chosen — it is the algebraic inverse of the logit.forced by · fixing the link function fixes its inverse; there is no remaining freedom - 5This also fixes the loss. Under a Bernoulli likelihood, the negative log-likelihood is
−[y·log p + (1−y)·log(1−p)]— cross-entropy. And it makes the gradient come out as(ŷ − y)·x: the same clean form as linear regression, because the sigmoid's derivative cancels exactly against the log in the loss.forced by · the loss must be the log-likelihood of the distribution the link function implies
Therefore the sigmoid is the unique inverse of the log-odds link, and cross-entropy is its matching likelihood. Neither was a design taste; both fall out of "model the log-odds linearly".
And note what this predicts: each coefficient must be interpretable as a change in log-odds per unit feature — so exp(βⱼ) is a multiplicative odds ratio. A coefficient of 0.7 means the odds multiply by about 2 per unit, regardless of the baseline. It also predicts why pairing sigmoid with squared error is a mistake: that combination is non-convex and its gradient carries a σ'(z) factor that vanishes when the model is confidently wrong, stalling learning exactly where you most need it. Cross-entropy has no such factor — by derivation.
Picture two spaces joined by a fixed transform. In log-odds space everything is linear, unbounded, and additive — features contribute independently, coefficients simply add, and the decision boundary is a plain hyperplane. In probability space everything is squashed into (0,1), where the same additive change means a lot near p = 0.5 and almost nothing near 0 or 1.
The model does all its thinking in the first space. The sigmoid is just the window through which you read the answer. Every confusing property of logistic regression — saturating gradients, coefficients as odds ratios, why an intercept shift is exactly a threshold shift — becomes obvious once you know which space you are standing in.
- Coefficients live in log-odds.
exp(β)is the odds ratio; the effect on probability depends on where you already are on the curve. - The decision boundary is where z = 0, i.e. p = 0.5. Changing the threshold is exactly shifting the intercept — same model, different operating point.
- Perfectly separable data has no finite optimum: the model can always increase confidence by scaling β up, so weights diverge. Regularisation is what makes the solution exist at all.
- Log-loss punishes confident errors without bound — predicting 0.999 on a true 0 costs enormously. That is what forces honest probabilities rather than just correct rankings.
Fire this the moment you see: a 0.5 threshold on an imbalanced problem · coefficients read as "percentage points" · accuracy quoted for a rare-event classifier · weights that blow up or a convergence warning about separation · predicted probabilities used in an expected-value calculation without a calibration check.
For a tabular binary classification problem, do you ship logistic regression or gradient-boosted trees?
Always fit logistic regression first, even when you are certain trees will win. It costs minutes, it gives you a calibrated performance floor, and its coefficients are a fast leakage detector — an implausibly large weight on one feature usually means that feature encodes the answer. Skipping it is how teams ship a tuned booster that never beat a linear model nobody bothered to run.
Then let the gap decide. If boosting buys a few points and costs you explainability plus a calibration step, that is a genuine business tradeoff and not a technical one. And when logistic regression is close behind, remember that its probabilities are usually trustworthy without extra work — which matters more than raw AUC the moment a downstream system multiplies your output by a cost.
(c) Hands-on · 25 min
We're going to build logistic regression on the breast cancer dataset (binary classification, 30 features, ~570 samples), compare with sklearn, then tune the threshold using a PR curve.
# logreg_scratch.py — logistic regression, ~130 lines runnable.
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score, precision_recall_fscore_support, roc_auc_score,
precision_recall_curve, average_precision_score, confusion_matrix
)
np.random.seed(42)
# --- 1. Data + split ---
data = load_breast_cancer()
X, y = data.data, data.target # y=1 → benign, y=0 → malignant
print(f"X shape: {X.shape}, class balance: y=1 → {y.mean():.2%}")
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
scaler = StandardScaler().fit(X_train)
X_train_s = scaler.transform(X_train)
X_test_s = scaler.transform(X_test)
def add_bias(X):
return np.hstack([np.ones((X.shape[0], 1)), X])
Xtr = add_bias(X_train_s)
Xte = add_bias(X_test_s)
# --- 2. Sigmoid + cross-entropy ---
def sigmoid(z):
"""Numerically stable sigmoid. Avoids overflow for very large |z|."""
return np.where(z >= 0, 1 / (1 + np.exp(-z)), np.exp(z) / (1 + np.exp(z)))
def bce_loss(y, p, eps=1e-15):
"""Binary cross-entropy. Clip p to avoid log(0)."""
p = np.clip(p, eps, 1 - eps)
return -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))
# --- 3. Fit via gradient descent ---
def fit_logreg(X, y, lr=0.1, epochs=2000, verbose=False):
n, d = X.shape
theta = np.zeros(d)
for epoch in range(epochs):
z = X @ theta
p = sigmoid(z)
grad = (1 / n) * (X.T @ (p - y)) # ← identical shape to linreg gradient
theta = theta - lr * grad
if verbose and epoch % 200 == 0:
print(f" epoch {epoch:4d}: loss={bce_loss(y, p):.4f}, acc={(p > 0.5).astype(int).mean():.3f}")
return theta
theta = fit_logreg(Xtr, y_train, lr=0.1, epochs=2000, verbose=True)
# --- 4. Predict + evaluate ---
p_test = sigmoid(Xte @ theta)
yhat_default = (p_test > 0.5).astype(int)
acc = accuracy_score(y_test, yhat_default)
auc = roc_auc_score(y_test, p_test)
pr_auc = average_precision_score(y_test, p_test)
print(f"\n[scratch @ threshold 0.5] acc={acc:.4f} ROC-AUC={auc:.4f} PR-AUC={pr_auc:.4f}")
# --- 5. sklearn sanity check ---
sk = LogisticRegression(penalty=None, max_iter=5000).fit(X_train_s, y_train)
p_sk = sk.predict_proba(X_test_s)[:, 1]
print(f"[sklearn @ threshold 0.5] acc={accuracy_score(y_test, sk.predict(X_test_s)):.4f} "
f"ROC-AUC={roc_auc_score(y_test, p_sk):.4f}")
# --- 6. Threshold tuning via PR curve ---
prec, rec, thr = precision_recall_curve(y_test, p_test)
f1 = 2 * prec * rec / (prec + rec + 1e-12)
best_idx = np.argmax(f1[:-1]) # skip last point (no threshold)
best_thr = thr[best_idx]
print(f"\nBest F1 = {f1[best_idx]:.4f} at threshold = {best_thr:.4f}")
print(f" → precision = {prec[best_idx]:.4f}, recall = {rec[best_idx]:.4f}")
yhat_tuned = (p_test > best_thr).astype(int)
print(f"[scratch @ tuned threshold] confusion matrix:\n{confusion_matrix(y_test, yhat_tuned)}")
# --- 7. Calibration check — reliability plot data ---
def reliability(y, p, n_bins=10):
"""Bin predictions by confidence, check if the fraction of positives matches."""
bins = np.linspace(0, 1, n_bins + 1)
idx = np.digitize(p, bins) - 1
idx = np.clip(idx, 0, n_bins - 1)
means_pred, means_true, counts = [], [], []
for b in range(n_bins):
mask = idx == b
if mask.sum() > 0:
means_pred.append(p[mask].mean())
means_true.append(y[mask].mean())
counts.append(int(mask.sum()))
return means_pred, means_true, counts
mp, mt, ct = reliability(y_test, p_test, n_bins=8)
print("\nReliability plot data (a well-calibrated model has mp ≈ mt):")
print(f" {'pred_prob':>10} {'true_frac':>10} {'count':>8}")
for a, b, c in zip(mp, mt, ct):
print(f" {a:>10.3f} {b:>10.3f} {c:>8d}")What each block does
Anatomy of the script
Downsample the negative class so the training data is 95 % positive / 5 % negative. Refit and check:
- Accuracy = ~95 % (looks great!)
- Precision on negative class = ~0.4, recall = ~0.1 (terrible)
- PR-AUC drops from 0.99 to ~0.6
Now try class_weight='balanced' in sklearn's LogisticRegression. Watch PR-AUC recover.
This is the reason you always report precision/recall + PR-AUC on imbalanced problems, never just accuracy.
(d) Production reality · 15 min
FICO, VantageScore, Experian all use logistic-regression-descended models to compute your credit score. They're heavily regulated (ECOA, FCRA in the US), which means every coefficient must be explainable to a human and provably not discriminatory on protected attributes.
Random forests + neural nets would score better by 1-2 AUC points, but their coefficients aren't interpretable and they can bias against protected classes in ways lawyers can't defend. So logistic regression rules.
For years, Facebook's News Feed ranking was a stack of logistic regressions predicting probabilities of like, comment, share. Around 2016 they started replacing pieces with deep learning (Wide & Deep, then DLRM). The lift was measurable but modest — 5-8 % improvement in engagement metrics.
The bigger story: the logistic regression baseline was so good that most of the work was feature engineering, not model choice.
Ad-tech ranks candidates by predicted click-through rate (pCTR). If pCTR is uncalibrated, the auction breaks — you'd overpay for ads that don't click. Google's original CTR model was a big logistic regression with feature crosses (Wide & Deep is the descendant paper). Cross-entropy loss + sigmoid output gave calibrated probabilities out of the box.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why sigmoid, not any other squash? (probabilistic interpretation)
- Where does cross-entropy come from? (MLE on Bernoulli)
- Why is 0.5 not always the right threshold? (cost of FP vs FN)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.