Search Tech Journey

Find topics, journeys and posts

6-month learning plan86 / 130
back to blog
mlintermediate 55m read

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.

🤖Machine LearningM11 · Classical ML· Session 086 of 130 90 min

🎯 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.

You will be able to
  • 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

A dimmer switch on a light bulb
🌍 Real world

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.

💻 Code world

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

Logistic regression in three sentences
  • 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

  1. 1838
    Logistic function · Verhulst
    Pierre-François Verhulst invents the S-curve to model population growth. Nothing to do with classification yet.
  2. 1944
    Berkson coins ‘logit’
    Joseph Berkson uses the sigmoid for bioassay. Log-odds get the name ‘logit.’
  3. 1958
    Cox popularises for statistics
    David Cox brings logistic regression into applied statistics as an alternative to probit.
  4. 1990s
    Standard in ad tech + finance
    Google, Facebook, and every credit bureau standardise on logistic regression for scoring.
  5. 2016
    Wide & Deep · Google
    LogReg + 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

Model each yᵢ as Bernoulli(pᵢ)
P(y=1|x) = p, P(y=0|x) = 1-p. Compact form: P(y|x) = pʸ (1-p)^(1-y).
step 1
Likelihood over dataset
L(θ) = ∏ᵢ pᵢ^yᵢ (1-pᵢ)^(1-yᵢ). Products are numerically bad — take the log.
step 2
Log-likelihood
ℓ(θ) = Σᵢ [yᵢ log pᵢ + (1-yᵢ) log(1-pᵢ)]. This is what we want to MAXIMISE.
step 3
Flip sign → loss
L(θ) = -ℓ(θ) = -Σᵢ [yᵢ log pᵢ + (1-yᵢ) log(1-pᵢ)]. Minimise this instead. This IS binary cross-entropy.
step 4
Gradient
After the sigmoid derivative magic cancels: ∇L = (1/n) · Xᵀ(p - y). Same shape as linear regression's gradient.
step 5

Metrics beyond accuracy — the whole zoo

Accuracy

% 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.
Precision · Recall · F1

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.
ROC-AUC · PR-AUC

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


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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.

  1. 1
    A 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
  2. 2
    Start 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
  3. 3
    Take 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
  4. 4
    Now invert that equation to recover p. Exponentiating gives p/(1−p) = e^z, and solving for p gives p = 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
  5. 5
    This 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

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.

Mental modelA linear ruler laid on log-odds

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.
🔔 Fires when you see

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.

The tradeoff

For a tabular binary classification problem, do you ship logistic regression or gradient-boosted trees?

Logistic regression
+ you gain convex objective so training is deterministic and reproducible; coefficients are directly explainable as odds ratios, which is often a regulatory requirement; probabilities tend to be well calibrated out of the box; inference is one dot product, so latency is negligible and the model ships anywhere — including into SQL as a CASE expression
− you pay you must supply non-linearity and interactions by hand through feature engineering, which is real, ongoing human labour; it is sensitive to feature scaling and to outliers; and on genuinely complex tabular signal it will lose to trees, sometimes by a lot
pick when you need explainability for compliance or trust, you need extremely low inference latency, the sample size is small relative to feature count, or you need a solid baseline before spending on anything else
Gradient-boosted trees
+ you gain learns interactions and non-linearities automatically, is invariant to monotone feature transforms so scaling and skew stop mattering, handles missing values natively, and is typically the strongest performer on heterogeneous tabular data
− you pay a hyperparameter surface you must actually search; probabilities often need post-hoc calibration before they can be used in expected-value decisions; explanations become approximate and attributional rather than exact; the model is larger and inference is slower; and it extrapolates not at all beyond the range of the training data
pick when predictive performance is the dominant objective, you have enough data to tune honestly, and approximate explanations are acceptable
Logistic regression on engineered features
+ you gain splines, bucketed features, and explicit interaction terms recover much of the non-linear headroom while keeping convexity, calibration, and a model you can read line by line — this is why it remained the standard in credit scoring long after trees existed
− you pay the feature engineering is the model, so quality depends entirely on domain knowledge and iteration speed; it does not scale to hundreds of features where the useful interactions are unknown
pick when the domain is well understood, explainability is mandatory, and you have the expertise to encode the known structure
What a senior engineer actually does

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

Stable sigmoid
Naive 1/(1+exp(-z)) overflows for very negative z. The np.where trick uses two formulas depending on sign — same math, no NaNs.
numerics
BCE loss with clipping
log(0) is -∞. Clip predictions to [1e-15, 1-1e-15] before log. Every deep learning library does this internally.
numerics
Gradient loop
Copy-paste of the linreg gradient loop with sigmoid inserted. Notice: the update rule is unchanged. That's the beauty of the cross-entropy + sigmoid pairing.
core
Default threshold @ 0.5
Turns probabilities into 0/1 predictions. The default that everyone ships and nobody tunes.
predict
sklearn comparison
sklearn uses L-BFGS by default (a second-order optimiser). Should match your GD to ~2 decimals unless you overfit slightly differently.
validate
PR curve threshold tuning
Grid over all possible thresholds, pick the one that maximises F1 (or whatever your business cost function is).
tune
Reliability check
If the model says ‘I'm 80 % sure,’ is it right 80 % of the time? LogReg is naturally well-calibrated; SVMs, boosted trees, and neural nets are not.
calibration
Try itMake the dataset imbalanced and watch accuracy stop meaning anything

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.

💡 Hint · Compare accuracy vs PR-AUC before and after. Accuracy will look ‘fine’ even when the model completely ignores the minority class.

(d) Production reality · 15 min

War story Every credit bureau · every daybillions of decisions per year
🔥 What broke

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.

🧯 The fix
Modern credit models are massive logistic regressions with 100s of hand-engineered features + monotonic constraints. The coefficient on every feature is inspected by a compliance team before deployment.
🎓 Lesson to steal
‘Best AUC’ is not always ‘best model.’ Interpretability, monotonicity, and legal defensibility are first-class constraints in many industries. Logistic regression's fixed structure is a feature, not a limitation.
War story Facebook News Feed · 2013–2019· 2019billions of daily rankings
🔥 What broke

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.

🧯 The fix
Facebook's DLRM paper (2019) explicitly documents that LogReg-style linear parts remain in the model — a Wide-and-Deep architecture combines memorisation (linear) with generalisation (deep). Neither replaces the other.
🎓 Lesson to steal
‘Deep learning replaces classical ML’ is largely a myth for tabular data. The models that scale to billions of users are hybrids — LogReg for the linear parts, DNNs for the interactions.
Post-mortem
War story Google Ads · CTR predictiontrillion+ predictions per day
🔥 What broke

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.

🧯 The fix
When they replaced parts with neural networks, they had to add explicit calibration layers (Platt scaling, isotonic regression) because uncalibrated NN outputs broke the downstream auction economics.
🎓 Lesson to steal
If your downstream system needs a probability (auction, cost-weighted decision, risk score), calibrate. LogReg is calibrated by construction; other models need Platt or isotonic on top.

Where this shows up in the rest of the plan

Logistic regression is the DNA of every classifier
S087 · Regularization
L2 → default in sklearn. L1 → feature selection. Elastic Net → both.
S088 · Bias–variance
LogReg tends to underfit (high bias). Regularization + polynomial features + interactions rebalance.
S089 · Decision trees
The non-linear counterpoint. Trees carve axis-aligned regions; LogReg draws hyperplanes.
S095 · Neural network fundamentals
A single-layer NN with sigmoid output IS logistic regression. Stack layers → non-linear classifier.
S102 · Softmax + multinomial
LogReg generalised to K classes. Same loss, K weight vectors, softmax normalisation.
S119 · Recommender systems
CTR/CVR heads in every ranker are logistic regression + calibration.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why sigmoid, not any other squash? (probabilistic interpretation)
  2. Where does cross-entropy come from? (MLE on Bernoulli)
  3. 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.