Logistic Regression (Binary vs Multiclass)
What you’ll learn
- why a classifier is named “regression”, and what it is actually regressing
- the sigmoid derived from log-odds, not pulled out of the air
- log loss, why it is convex, and why it punishes confident mistakes so harshly
- a full gradient step computed by hand on four rows
- softmax regression for more than two classes, and why it is not the same as multilabel
- how moving the threshold trades precision against recall on a real dataset
Intuition
A linear model outputs any real number. A probability has to live between 0 and 1. Logistic regression is the smallest possible bridge between the two: compute the linear score exactly as in Phase 3, then squash it.
The squashing function is not arbitrary. Ask instead: what quantity can a linear model predict without bounds? The answer is the log-odds. Odds run from 0 to infinity; their logarithm runs from to — exactly the range a linear model produces. So model the log-odds linearly, then invert to recover the probability. The inverse is the sigmoid.
flowchart LR X["Features x"] --> Z["Linear score
z = w·x + b"] Z --> S["Sigmoid
p = 1 / (1 + e^-z)"] S --> P["Probability p"] P --> T["Threshold (default 0.5)"] T --> Y["Predicted class"]
The math
From odds to the sigmoid
For a probability , the odds are and the log-odds (or logit) are their logarithm. Model that linearly:
Solve for :
The sigmoid is not a design choice; it is what you get by insisting that a linear model predicts log-odds. This also hands you the interpretation of a coefficient: is the change in log-odds per unit of , so is the multiplicative change in the odds.
Log loss
Squared error against a sigmoid output is non-convex, so gradient descent could get stuck. Instead use the negative log-likelihood of a Bernoulli outcome:
Only one term is ever active per row: if the cost is , if it is . Both blow up as the model becomes confidently wrong.
The gradient — the surprise
Differentiate log loss with respect to the weights and, after the algebra cancels, you get:
That is exactly the linear regression gradient, with the prediction replaced by the sigmoid output. The messy term produced by the chain rule cancels against the derivative of the logarithm. Log loss is the loss that makes logistic regression as simple to optimise as least squares — a strong argument that it is the right loss, not merely a convenient one.
There is no closed form, but is convex in , so gradient descent always reaches the global minimum.
Worked example by hand
Four emails, one feature: the number of links. Start from , .
| links | true | loss | |||
|---|---|---|---|---|---|
| 1 | 1 | 0 | −1.5 | 0.1824 | |
| 2 | 2 | 0 | −0.5 | 0.3775 | |
| 3 | 3 | 1 | +0.5 | 0.6225 | |
| 4 | 4 | 1 | +1.5 | 0.8176 |
Step 1 — the cost.
Step 2 — the errors .
Step 3 — the gradients.
Step 4 — one step at .
The intercept gradient is exactly zero because the data is symmetric about , which is where already places the boundary. Only the slope needs to grow — the model is being told to become more confident, not to move the boundary. Left running, diverges toward infinity, because perfectly separable data has no finite maximum-likelihood solution. Regularisation is what stops that, which is why scikit-learn applies an penalty by default.
See it move
From scratch
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def log_loss(y, p, eps=1e-15):
p = np.clip(p, eps, 1 - eps) # never take log(0)
return float(-(y * np.log(p) + (1 - y) * np.log(1 - p)).mean())
x = np.array([1.0, 2.0, 3.0, 4.0])
y = np.array([0.0, 0.0, 1.0, 1.0])
w, b = 1.0, -2.5
p = sigmoid(w * x + b)
print(p.round(4)) # [0.1824 0.3775 0.6225 0.8176]
print(round(log_loss(y, p), 4)) # 0.3377
grad_w = float(((p - y) * x).mean())
grad_b = float((p - y).mean())
print(round(grad_w, 4), round(grad_b, 4)) # -0.2312 0.0
w, b = w - 1.0 * grad_w, b - 1.0 * grad_b
print(round(w, 4), round(b, 4)) # 1.2312 -2.5
print(round(log_loss(y, sigmoid(w * x + b)), 4)) # 0.318 — the cost fellimport numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def log_loss(y, p, eps=1e-15):
p = np.clip(p, eps, 1 - eps) # never take log(0)
return float(-(y * np.log(p) + (1 - y) * np.log(1 - p)).mean())
x = np.array([1.0, 2.0, 3.0, 4.0])
y = np.array([0.0, 0.0, 1.0, 1.0])
w, b = 1.0, -2.5
p = sigmoid(w * x + b)
print(p.round(4)) # [0.1824 0.3775 0.6225 0.8176]
print(round(log_loss(y, p), 4)) # 0.3377
grad_w = float(((p - y) * x).mean())
grad_b = float((p - y).mean())
print(round(grad_w, 4), round(grad_b, 4)) # -0.2312 0.0
w, b = w - 1.0 * grad_w, b - 1.0 * grad_b
print(round(w, 4), round(b, 4)) # 1.2312 -2.5
print(round(log_loss(y, sigmoid(w * x + b)), 4)) # 0.318 — the cost fellEvery figure matches the hand calculation, and the cost drops from 0.3377 to 0.3180 in one step.
With scikit-learn
Binary: one feature, one boundary
import numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
iris = load_iris()
X = iris.data[:, 3:] # petal width only
y = (iris.target == 2).astype(int) # 1 if Iris virginica
model = LogisticRegression().fit(X, y)
print(model.predict([[1.7], [1.5]])) # [1 0]
print(model.predict_proba([[2.0]]).round(3)) # [[0.187 0.813]]
boundary = -model.intercept_[0] / model.coef_[0][0]
print(f"p = 0.5 at petal width {boundary:.4f} cm") # 1.6603 cmimport numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
iris = load_iris()
X = iris.data[:, 3:] # petal width only
y = (iris.target == 2).astype(int) # 1 if Iris virginica
model = LogisticRegression().fit(X, y)
print(model.predict([[1.7], [1.5]])) # [1 0]
print(model.predict_proba([[2.0]]).round(3)) # [[0.187 0.813]]
boundary = -model.intercept_[0] / model.coef_[0][0]
print(f"p = 0.5 at petal width {boundary:.4f} cm") # 1.6603 cmThe boundary is where , so — a number you can read off the coefficients directly and quote to a botanist.
Multiclass: softmax regression
For classes the model computes one score per class and normalises them:
The predicted class is , and the loss generalises to cross entropy, which reduces to ordinary log loss when .
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
iris = load_iris()
X = iris.data[:, (2, 3)] # petal length, petal width
y = iris.target # three classes
model = LogisticRegression(C=10, max_iter=1000).fit(X, y)
print(model.predict([[5, 2]])) # [2] -> virginica
print(model.predict_proba([[5, 2]]).round(3)) # [[0. 0.057 0.943]]
print(f"training accuracy {model.score(X, y):.4f}") # 0.9600from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
iris = load_iris()
X = iris.data[:, (2, 3)] # petal length, petal width
y = iris.target # three classes
model = LogisticRegression(C=10, max_iter=1000).fit(X, y)
print(model.predict([[5, 2]])) # [2] -> virginica
print(model.predict_proba([[5, 2]]).round(3)) # [[0. 0.057 0.943]]
print(f"training accuracy {model.score(X, y):.4f}") # 0.9600Choosing the threshold
predict()predict() uses 0.5. Nothing about your problem chose that number.
Reading the plot
| Threshold | Precision | Recall | F1 | When you would pick it |
|---|---|---|---|---|
| 0.1 | 0.818 | 0.984 | 0.894 | Screening — missing a case is unacceptable |
| 0.3 | 0.859 | 0.953 | 0.904 | Cautious triage |
| 0.5 | 0.938 | 0.938 | 0.938 | The default, balanced here by coincidence |
| 0.7 | 1.000 | 0.922 | 0.959 | Automated action — a false alarm is costly |
| 0.9 | 1.000 | 0.875 | 0.933 | Only act on near-certainty |
On this dataset the best F1 comes from 0.7, not 0.5. That is a free improvement, available from a model that was already trained.
APIsklearn.linear_model.LogisticRegression
Assumes
- Log-odds of the positive class are linear in the features
- Observations are independent
- Little or no perfect multicollinearity
- Enough samples per feature — roughly 10 events per predictor is the usual rule
Cost
- train
O(m·n·i)- predict
O(n)- memory
O(n)
m = samples, n = features, i = solver iterations
Hyperparameters that matter
Cdefault 1.0INVERSE regularisation strength — smaller C means a stronger penalty. The opposite of alpha elsewhere in scikit-learn.penaltydefault l2'l1' produces sparse coefficients (needs solver='liblinear' or 'saga'); 'elasticnet' needs 'saga'.class_weightdefault None'balanced' reweights the loss by inverse class frequency — the first thing to try on imbalanced data.max_iterdefault 100Raise it when you see a ConvergenceWarning; unscaled features are the usual cause.solverdefault lbfgs'saga' for large or sparse data and L1; 'liblinear' for small binary problems.
Reach for it when
- You need calibrated probabilities, not just labels
- You must explain each feature's effect as a change in odds
- You want a fast, strong baseline before trying anything complex
- The classes are close to linearly separable in your feature space
Look elsewhere when
- The boundary is genuinely non-linear and you cannot engineer the features
- Features vastly outnumber samples without regularisation
- You need to model complex feature interactions automatically — use a tree ensemble
Pitfalls
Compare
| Model | Outputs probability | Boundary | Handles non-linearity | Interpretability |
|---|---|---|---|---|
| Logistic Regression | Yes, calibrated | Linear | Only via engineered features | Very high — odds ratios |
| Linear SVM | No (needs Platt scaling) | Linear, max-margin | Via kernels | Moderate |
| KNN | Yes, but coarse | Local | Naturally | Low |
| Naive Bayes | Yes, poorly calibrated | Quadratic-ish | Limited | Moderate |
| Decision Tree | Yes, coarse | Axis-aligned steps | Naturally | High for small trees |
| Gradient Boosting | Yes, needs calibration | Complex | Naturally | Low |
Why is the sigmoid the right function to squash a linear score into a probability?
Log-odds range over all real numbers, which is exactly what a linear model outputs. Inverting the logit to recover the probability gives the sigmoid — it is derived, not chosen.
Show answer
B — It is the inverse of the logit, so modelling log-odds linearly produces it automatically — Log-odds range over all real numbers, which is exactly what a linear model outputs. Inverting the logit to recover the probability gives the sigmoid — it is derived, not chosen.
What is remarkable about the gradient of log loss for logistic regression?
The sigmoid derivative cancels against the log derivative, leaving the same clean expression as least squares. That cancellation is a strong hint log loss is the natural pairing for the sigmoid.
Show answer
B — It is identical in shape to the linear regression gradient: the mean of (prediction minus target) times the feature — The sigmoid derivative cancels against the log derivative, leaving the same clean expression as least squares. That cancellation is a strong hint log loss is the natural pairing for the sigmoid.
In LogisticRegression, what does a small value of C mean?
C is the inverse of regularisation strength, the reverse of the alpha convention used by Ridge and Lasso. C = 0.01 penalises heavily; C = 100 barely penalises at all.
Show answer
B — Strong regularisation, because C is the inverse of the penalty strength — C is the inverse of regularisation strength, the reverse of the alpha convention used by Ridge and Lasso. C = 0.01 penalises heavily; C = 100 barely penalises at all.
Your model's best F1 occurs at a threshold of 0.7 rather than 0.5. What should you do?
predict() simply compares predict_proba() against 0.5. Comparing against 0.7 instead is a one-line change that costs nothing and improves the metric you care about.
Show answer
B — Use 0.7 — the threshold is a free parameter applied after training, and no retraining is needed — predict() simply compares predict_proba() against 0.5. Comparing against 0.7 instead is a one-line change that costs nothing and improves the metric you care about.
🧪 Try It Yourself
Exercise 1 – Implement the sigmoid
Exercise 2 – Compute log loss by hand
Exercise 3 – One gradient step
Exercise 4 – Read the decision boundary off the coefficients
Exercise 5 – Move the threshold, change the metrics
Recap
- Model the log-odds linearly and invert; the sigmoid falls out of the algebra.
- Log loss is the negative Bernoulli log-likelihood, convex, and it punishes confident errors without limit.
- Its gradient is — the same shape as least squares, because the sigmoid and log derivatives cancel.
- Hand-worked on four rows: cost 0.3377, , .
- Softmax extends to mutually exclusive classes; independent sigmoids handle multilabel.
- The threshold is a post-training decision. On the breast-cancer model it moves precision from 0.82 to 1.00 while recall falls from 0.98 to 0.88.
CCis the inverse of regularisation strength, and features must be scaled.
Exercise 6 – Read a coefficient as an odds ratio
Next
Continue to K-Nearest Neighbors (KNN) — a classifier with no training step at all, that decides purely by looking at whoever is closest.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
