What it actually does
Take the linear model, then squash its unbounded output through a sigmoid so it lands in [0, 1] and can be read as a probability. Each coefficient answers: how much does one unit of this feature move the log-odds? The decision boundary is still a hyperplane — the sigmoid changes what the output means, not what shapes the model can separate.
- The log-odds are linear in the features, which is what feature engineering exists to arrange
- Observations are independent — violated by overlapping label windows and by repeated measures
- Features are not near-perfectly collinear
- Classes are not perfectly separable, or regularization is present to handle it
Loss function
Negative log-likelihood under a Bernoulli model: penalize confident wrong predictions heavily and confident right ones barely. The asymmetry is deliberate and matters — squared error would let a prediction of 0.99 on a negative case off far too lightly, and it is why log loss, not accuracy, is the training objective.
- the logistic function, 1/(1+e^{-z})
- the observed class label
- coefficients, interpretable as log-odds per unit of feature
How it is minimized
Convex minimization by IRLS, L-BFGS, or gradient descent — no closed form exists
- the residual — identical in form to linear regression's
- learning rate, for first-order methods
The gradient has exactly the same shape as linear regression's: features weighted by residuals. That is not a coincidence — both are generalized linear models, and the link function is the only thing that changes. There is no closed form because the sigmoid is nonlinear in theta, but the objective is strictly convex under any positive regularization, so the optimum is unique and every reasonable solver reaches it. L-BFGS is the standard default; IRLS converges in fewer iterations but costs a Hessian solve each one.
C (inverse regularization)
Smaller means stronger shrinkage; required for separable data (0.01 to 100, tuned on a log grid)
penalty
L2 for stability, L1 for sparsity, elastic-net for both (l2 by default)
class_weight
Reweights the loss under imbalance instead of resampling (None or balanced)
solver
lbfgs for dense, saga for L1 or very large n (lbfgs, saga, newton-cholesky)
Strictly convex with regularization, so convergence is to the unique global optimum and is reliable. The one genuine failure is perfect separation: when a hyperplane splits the classes exactly, the likelihood is maximized by driving coefficients to infinity, and an unregularized fit will either not converge or return absurd values with astronomical standard errors. Any regularization removes it. Unscaled features slow convergence badly but do not change the optimum.
O(nd) per gradient step; IRLS adds O(d³) per iteration for the Hessian solve. Fits on millions of rows in seconds, which is why it remains the default baseline.
How, where, and why it is used
Time-Series Forecasting
AdaptedForecast a binary event rather than a level — will demand exceed capacity next week, will this machine fail in the next 30 days. Lag features, rolling statistics, and calendar terms become the covariates, and the target is the event indicator at horizon h.
- Threshold-exceedance forecasting where the decision is binary anyway
- Churn and failure prediction over a fixed forward window
Reasonable when the deliverable genuinely is a probability of an event rather than a value, and the calibrated output plugs straight into an expected-cost decision. Not a competitor to ARIMA — it discards the autocorrelation structure entirely and only sees whatever the lag features encode.
- Lags at the seasonal period, plus rolling means and standard deviations
- Define the label window carefully — an overlapping window leaks the target across rows
Rolling-origin backtesting scored with log loss and calibration, never accuracy. Split by time, always.
- Overlapping label windows create correlated rows and wildly optimistic validation scores
- Rolling features computed before the split leak the future backwards
Anomaly Detection
AdaptedOnly usable when labelled anomalies exist, which is the minority case. Fit with heavy class weighting and threshold the predicted probability against an alert budget.
- Fraud scoring where confirmed labels arrive via chargebacks
- Quality control with a historical defect log
Appropriate when anomalies are labelled and resemble each other. It fails at the thing anomaly detection usually needs — catching a novel failure mode — because a discriminative model can only recognize patterns it was shown. When labels are absent or anomalies are genuinely new, an unsupervised detector is the right tool.
- Class weighting rather than resampling, which preserves calibration
- Strictly time-based splits; random splits leak future patterns backwards
PR-AUC and recall at a fixed false-positive budget. ROC-AUC flatters every model at extreme imbalance.
- Cannot detect an anomaly type absent from training, by construction
- Resampling to balance the classes destroys probability calibration
Optimization
ViableSupplies the calibrated probability that an expected-value decision rule multiplies against costs. The optimization is downstream: choose the action maximizing expected value, where the probability comes from here.
- Approve/decline thresholds set from expected loss rather than a fixed cutoff
- Propensity models feeding a causal estimator or a targeting allocation
The value is calibration, not accuracy. A decision rule multiplying probability by cost needs the probability to mean what it says — an uncalibrated model that ranks perfectly will still pick the wrong threshold, and logistic regression is calibrated by construction when fit on the log-loss it optimizes.
- Avoid resampling and post-hoc thresholding tricks that break calibration
- Scale features so regularization penalizes them comparably
Calibration curves and Brier score alongside the decision metric; expected value at the chosen operating point.
- Optimizing a threshold on the same data used to fit produces an over-optimistic operating point
- Regularization shrinks probabilities toward the base rate, which mildly decalibrates at high C
Risk & Fraud
Primary fitFit on tabular application or transaction features with monotonic constraints where regulation requires them, and produce both a score and the reason codes explaining it.
- Credit scoring, where adverse-action reasons are legally mandated
- Insurance underwriting under regulatory review
The default in regulated credit specifically because it is explainable and auditable. Gradient boosting scores better and cannot tell an applicant why they were declined in a form that survives a regulator — so here explainability is a hard constraint, not a preference, and this is the model that satisfies it.
- Weight-of-evidence binning, which linearizes the relationship and yields interpretable bins
- Monotonic constraints so more income can never reduce the approval odds
KS statistic and Gini alongside PR-AUC, with stability testing across time periods and demographic slices.
- Proxy variables reintroduce protected attributes indirectly and must be tested for
- Label latency means recent applications are not yet fully outcome-labelled
Natural Language
ViableTF-IDF or bag-of-words features into a linear classifier — the pre-transformer baseline that remains stubbornly competitive on narrow tasks.
- Small-corpus document and intent classification
- The baseline any fine-tuned transformer must beat to justify its cost
On a few thousand labelled documents in a narrow domain, this trains in seconds, is fully interpretable at the token level, and often lands within a point or two of a fine-tuned encoder. It loses decisively once word order, negation, or long-range context matter.
- TF-IDF with n-grams to recover a little local word order
- Sublinear term frequency scaling, which usually helps on longer documents
Macro-F1 on a held-out split, reported against the transformer alternative so the cost comparison is explicit.
- Bag-of-words cannot represent negation — "not good" and "good" share a token
- Vocabulary drift silently degrades the model as new terms appear
What it costs to run
Seconds to minutes even on millions of rows. Cheap enough that hyperparameter search is unconstrained.
One dot product and one exponential — sub-microsecond, and small enough to run inside a database query or on an edge device.
Weekly to monthly typically; more often in adversarial settings like fraud where the population actively shifts.
- Track calibration, not just discrimination — a model can keep its AUC while its probabilities drift meaningfully
- Watch coefficient stability across refits; a sign flip is a louder signal than a small metric drop
- Monitor the score distribution, since a shifting base rate silently invalidates a fixed threshold
- The scaler and encoder are part of the model — persist them together or predictions change silently
- Unseen categorical levels produce a column mismatch at inference; pin the encoder rather than re-deriving it
- A threshold tuned on one base rate is wrong at another, so it must be revisited whenever the population shifts
Pros and cons, in context
Outputs calibrated probabilities by construction
Decisive when a downstream rule multiplies probability by cost. Many stronger classifiers rank better while being badly calibrated, which makes their thresholds meaningless.
Coefficients are directly interpretable as log-odds
What makes it legally viable in credit and insurance, where an adverse decision must come with a reason. Worthless when only the ranking is acted on.
Convex objective with a unique optimum
No seeds, no restarts, reproducible fits — which matters far more in audited pipelines than a point of AUC.
Extremely cheap to fit and to serve
Lets you retrain continuously and deploy anywhere. Irrelevant when accuracy dominates and compute is abundant.
Linear decision boundary only
A hard ceiling wherever interactions dominate, which is where gradient boosting wins outright on tabular data. Not a limitation at all if the log-odds really are close to linear.
Requires manual feature engineering for interactions
Every interaction must be constructed by hand, which is real work that trees do automatically — and the main practical reason teams migrate away from it.
Perfect separation breaks an unregularized fit
Coefficients run to infinity and standard errors become meaningless. Trivially fixed by regularization, but surprising the first time it happens.
Sensitive to feature scaling under regularization
The penalty is scale-dependent, so unscaled features are penalized unevenly. Harmless when unregularized, silently distorting when not.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Logistic regression by gradient descent - the objective, transcribed.
The gradient has the same shape as linear regression's: features weighted by
residuals. Only the link function differs, which is what makes both of these
generalized linear models.
"""
import math
def sigmoid(z):
# Split by sign to avoid exp() overflowing on large negative z.
if z >= 0:
return 1.0 / (1.0 + math.exp(-z))
exp_z = math.exp(z)
return exp_z / (1.0 + exp_z)
def fit(X, y, lr=0.1, epochs=1_000):
n = len(X)
d = len(X[0])
theta = [0.0] * d
bias = 0.0
for _ in range(epochs):
grad = [0.0] * d
grad_bias = 0.0
for i in range(n):
z = bias
for j in range(d):
z += theta[j] * X[i][j]
residual = sigmoid(z) - y[i] # exactly the linear-model form
for j in range(d):
grad[j] += residual * X[i][j]
grad_bias += residual
for j in range(d):
theta[j] -= lr * grad[j] / n
bias -= lr * grad_bias / n
return theta, bias
def log_loss(X, y, theta, bias):
"""Negative log-likelihood. Clipped, because log(0) is -inf and one
confident mistake would otherwise make the whole loss infinite."""
total = 0.0
for x, target in zip(X, y):
z = bias + sum(t * v for t, v in zip(theta, x))
p = min(max(sigmoid(z), 1e-15), 1.0 - 1e-15)
total -= target * math.log(p) + (1 - target) * math.log(1.0 - p)
return total / len(y)O(n·d) per epoch in pure Python — roughly 100x slower than the vectorized form.
How this language actually wants it written.
"""Logistic regression - typed, validated, calibration-aware."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass(frozen=True)
class ClassifierReport:
"""A score is not enough — a threshold decision needs calibration too."""
model: Pipeline
log_loss: float
brier_score: float
base_rate: float
@property
def is_calibrated(self) -> bool:
"""Brier below the base-rate variance means it beats predicting the
base rate for everyone — a low bar, and one models do fail."""
return self.brier_score < self.base_rate * (1.0 - self.base_rate)
def fit_classifier(
X: Matrix,
y: Vector,
C: float = 1.0,
balanced: bool = False,
) -> ClassifierReport:
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
if not np.isin(y, (0, 1)).all():
raise ValueError("y must be binary 0/1")
if len(np.unique(y)) < 2:
raise ValueError("y contains only one class - nothing to separate")
# Scaling is inside the pipeline, not applied beforehand: the L2 penalty is
# scale-dependent, and a scaler fitted separately will silently drift from
# the model it was fitted with.
model = Pipeline(
[
("scale", StandardScaler()),
(
"clf",
LogisticRegression(
C=C,
max_iter=1_000,
class_weight="balanced" if balanced else None,
),
),
]
)
model.fit(X, y)
probabilities = model.predict_proba(X)[:, 1]
clipped = np.clip(probabilities, 1e-15, 1 - 1e-15)
return ClassifierReport(
model=model,
log_loss=float(-np.mean(y * np.log(clipped) + (1 - y) * np.log(1 - clipped))),
brier_score=float(np.mean((probabilities - y) ** 2)),
base_rate=float(y.mean()),
)
def threshold_for_expected_cost(
probabilities: Vector, cost_false_positive: float, cost_false_negative: float
) -> float:
"""The expected-cost-minimizing cutoff, which is a ratio of costs and has
nothing to do with 0.5. Defaulting to 0.5 silently assumes the two errors
are equally expensive, which they almost never are."""
if cost_false_positive <= 0 or cost_false_negative <= 0:
raise ValueError("costs must be positive")
return cost_false_positive / (cost_false_positive + cost_false_negative)What changed: Scaling moves inside a pipeline so it cannot drift from the model it was fitted with, the report carries calibration metrics rather than only discrimination, and the decision threshold is derived from costs instead of defaulting to 0.5 — which silently assumes false positives and false negatives cost the same.
One L-BFGS fit; converges in tens of iterations on scaled features.
Faster, and the justification for each change.
"""Logistic regression - stable log-loss, single-pass gradient, warm starts."""
import numpy as np
from numpy.typing import NDArray
from scipy.special import expit, log_expit
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
def log_loss_stable(X: Matrix, y: Vector, theta: Vector) -> float:
"""Log loss computed from logits directly, never from probabilities.
The naive route (sigmoid, then clip, then log) loses precision exactly
where it matters - on confident predictions, whose contribution underflows
to the clip bound and stops being distinguishable. log_expit is the
numerically stable log-sigmoid, so no clipping is needed at all.
"""
logits = X @ theta
# log(sigma(z)) for positives, log(1 - sigma(z)) = log(sigma(-z)) for negatives.
return float(-np.mean(y * log_expit(logits) + (1 - y) * log_expit(-logits)))
def gradient(X: Matrix, y: Vector, theta: Vector, out: Vector) -> Vector:
"""Gradient written into a caller-owned buffer.
Called once per optimizer iteration, so a fresh (d,) allocation per call
is an allocation per iteration.
"""
residual = expit(X @ theta)
residual -= y # in place; no second array
np.dot(X.T, residual, out=out)
out /= X.shape[0]
return out
def fit_newton(X: Matrix, y: Vector, l2: float = 1.0, iterations: int = 25) -> Vector:
"""IRLS / Newton steps.
Second-order converges in tens of iterations rather than thousands, and at
modest d the O(d^3) solve per step is far cheaper than the extra passes
over the data that first-order methods need.
"""
X = np.ascontiguousarray(X, dtype=np.float64)
n, d = X.shape
theta = np.zeros(d, dtype=np.float64)
grad_buffer = np.empty(d, dtype=np.float64)
for _ in range(iterations):
p = expit(X @ theta)
# w = p(1-p), the IRLS weights. Computed in place to avoid two temporaries.
w = p * (1.0 - p)
np.maximum(w, 1e-10, out=w) # guard the Hessian against singularity
gradient(X, y, theta, grad_buffer)
grad_buffer += l2 * theta / n
# Hessian = X^T W X + l2 I. einsum fuses the scaling and the product,
# so the n x d weighted copy of X is never materialized.
hessian = np.einsum("ij,i,ik->jk", X, w, X) / n
hessian[np.diag_indices(d)] += l2 / n
# solve(), not inv(): forming the inverse squares the condition number
# and costs more.
theta -= np.linalg.solve(hessian, grad_buffer)
return thetaWhat changed: Log loss is computed from logits with a stable log-sigmoid rather than sigmoid-then-clip, which preserves precision exactly on the confident predictions the clip was destroying. The gradient writes into a reusable buffer, and the optimizer becomes second-order — tens of iterations instead of thousands — with the Hessian built by einsum so the weighted copy of the design matrix never exists.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
einsum fuses the IRLS weighting into the Gram product, so the n x d weighted copy of X that the readable form would build is never materialized.
Trade-off: einsum does not always dispatch to a BLAS GEMM; at large d an explicit (X * w[:, None]).T @ X can be faster despite the temporary.
Pre-allocate output arrays and use in-place operations
The gradient buffer and in-place residual subtraction remove two (n,) and one (d,) allocation from every optimizer iteration.
Trade-off: The out= parameter makes the function non-reentrant — two threads sharing a buffer would corrupt each other silently.
Replace a closed-form solve with a numerically stabler factorization
np.linalg.solve factorizes the Hessian rather than inverting it, which halves the condition-number sensitivity and costs less.
Trade-off: A fresh factorization every Newton step; caching one across steps would be faster but wrong, since the Hessian changes.
O(nd² + d³) per Newton step, ~25 steps. Illustrative, not a measured benchmark.