Skip to content

Logistic regression

Chapter 16 12 code blocks 3426 words

The code in this chapter uses these filesrun it on your own machine

The scene: what if we predict 0 and 1 with a linear model

We have two targets. In Chapter 15 we predicted narx_usd with linear regression, successfully.

Now we go back to ijaraga_berildi_30k. It's 0 or 1. The natural question: can we use the same model?

Let's try. It's the quickest lesson there is.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1). The target ijaraga_berildi_30k means rented within 30 days.

python21 lines
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel"]
X, y = df[num], df["ijaraga_berildi_30k"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

linear = LinearRegression().fit(X_tr, y_tr)
b = linear.predict(X_te)

print(f"The target is only 0 or 1: {sorted(y.unique().tolist())}")
print()
print(f"The linear model's predictions:")
print(f"  smallest : {b.min():.4f}")
print(f"  largest  : {b.max():.4f}")
print()
print(f"Predictions BELOW 0: {int((b < 0).sum())}")
print(f"Predictions ABOVE 1: {int((b > 1).sum())}")
Output
The target is only 0 or 1: [0, 1]

The linear model's predictions:
  smallest : -0.1000
  largest  : 0.9031

Predictions BELOW 0: 7
Predictions ABOVE 1: 0

There's the problem. On our data the model went down to -0.1000: it gave a negative prediction for 7 flats.

"This flat will be rented with a probability of minus 10 percent": that sentence is meaningless.

The upper limit wasn't broken here (the largest prediction is 0.9031). But that's luck: with different data, or with another feature, the model goes above 1 too. Nothing holds a linear function back.

Linear regression doesn't give probabilities

Many students apply LinearRegression to a 0/1 target and read the output as a "probability". That's a mistake: a linear function runs off to infinity, while a probability must stay between 0 and 1.

We need something that forces the output into [0, 1].

The sigmoid: squeezes any number into [0, 1]

python12 lines
import matplotlib.pyplot as plt

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

print(f"{'z':>8} {'sigmoid(z)':>12}   note")
print("-" * 46)
for z in [-6, -2, -1, 0, 1, 2, 6]:
    s = sigmoid(z)
    note = "sticks to 0" if s < 0.05 else ("sticks to 1" if s > 0.95 else
           ("EXACTLY in the middle" if z == 0 else "middle zone"))
    print(f"{z:>8} {s:>12.6f}   {note}")
Output
       z   sigmoid(z)   note
----------------------------------------------
      -6     0.002473   sticks to 0
      -2     0.119203   middle zone
      -1     0.268941   middle zone
       0     0.500000   EXACTLY in the middle
       1     0.731059   middle zone
       2     0.880797   middle zone
       6     0.997527   sticks to 1
python26 lines
z = np.linspace(-8, 8, 300)
figure, axes = plt.subplots(1, 2, figsize=(13.5, 4.6))

axes[0].plot(z, sigmoid(z), "b-", lw=2.5)
axes[0].axhline(0.5, color="gray", ls=":", lw=1)
axes[0].axvline(0, color="gray", ls=":", lw=1)
axes[0].axhline(0, color="red", ls="--", lw=1, alpha=.6)
axes[0].axhline(1, color="red", ls="--", lw=1, alpha=.6)
axes[0].set_xlabel("z = x @ w + b"); axes[0].set_ylabel("sigmoid(z) = probability")
axes[0].set_title("The sigmoid STAYS between 0 and 1", fontweight="bold")
axes[0].grid(alpha=.3)

axes[1].scatter(X_te.maydon_m2, y_te, alpha=.3, s=25, label="truth (0/1)", color="#5f6368")
order = np.argsort(X_te.maydon_m2.to_numpy())
axes[1].plot(X_te.maydon_m2.to_numpy()[order], b[order], "r-", lw=2, label="linear (leaves the bounds)")
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
log_one = Pipeline([("sc", StandardScaler()), ("m", LogisticRegression())]).fit(X_tr[["maydon_m2"]], y_tr)
p_one = log_one.predict_proba(X_te[["maydon_m2"]])[:, 1]
axes[1].plot(X_te.maydon_m2.to_numpy()[order], p_one[order], "g-", lw=2.5, label="logistic (inside 0-1)")
axes[1].axhline(0, color="k", ls=":", lw=.8); axes[1].axhline(1, color="k", ls=":", lw=.8)
axes[1].set_xlabel("maydon_m2"); axes[1].set_ylabel("probability")
axes[1].set_title("The two models side by side", fontweight="bold")
axes[1].legend(fontsize=8.5); axes[1].grid(alpha=.3)
fig("sigmoid.png", caption="Left: the sigmoid function. Right: the red line goes below 0 and above 1, while the green line stays within the bounds.")

Left: the sigmoid function. Right: the red line goes below 0 and above 1, while the green line stays within the bounds.

Left: the sigmoid function. Right: the red line goes below 0 and above 1, while the green line stays within the bounds.

Logistic regression = a linear model + the sigmoid:

In other words, x @ w + b from Chapter 12 hasn't changed; one function was added on top.

Why log loss, not MSE

The model gives a probability. How do we measure the error? Can't we use MSE?

python9 lines
def log_loss_one(p, y):
    p = np.clip(p, 1e-15, 1 - 1e-15)
    return -(y * np.log(p) + (1 - y) * np.log(1 - p))

print("When the truth is y=1 and the model gives various probabilities:")
print(f"{'predicted p':>12} {'MSE penalty':>13} {'log loss penalty':>18}")
print("-" * 46)
for p in [0.9, 0.7, 0.5, 0.3, 0.1, 0.01]:
    print(f"{p:>12} {(p - 1)**2:>13.4f} {log_loss_one(p, 1):>18.4f}")
Output
When the truth is y=1 and the model gives various probabilities:
 predicted p   MSE penalty   log loss penalty
----------------------------------------------
         0.9        0.0100             0.1054
         0.7        0.0900             0.3567
         0.5        0.2500             0.6931
         0.3        0.4900             1.2040
         0.1        0.8100             2.3026
        0.01        0.9801             4.6052

There's the difference. The model said p = 0.01, but the truth was 1: it was very confidently, completely wrong.

  • The MSE penalty: 0.98, small
  • The log loss penalty: 4.6, large

Log loss punishes a confident mistake much harder. And that's why the model learns to calibrate its probabilities properly.

The second reason: MSE breaks the bowl

Log loss has another advantage. If you combine the sigmoid with MSE, the cost function is not convex: it grows several dips, and gradient descent can get stuck in a local one (Chapter 13).

The sigmoid with log loss gives a convex bowl, with one global minimum. That's why it was chosen: not only for the penalty, but so that optimisation is reliable.

Coefficients: log-odds and the odds ratio

In linear regression a coefficient was easy to read: "if it goes up by 1 unit, the price changes by this much".

In logistic regression it's different. A coefficient gives the change in log-odds.

python12 lines
pipe = Pipeline([("sc", StandardScaler()), ("m", LogisticRegression(max_iter=2000))]).fit(X_tr, y_tr)
k = pipe.named_steps["m"].coef_[0]

table = pd.DataFrame({
    "feature": num,
    "coefficient (log-odds)": k.round(4),
    "odds ratio (e^k)": np.exp(k).round(4),
}).assign(meaning=lambda d: [
    f"up 1 std -> odds {'up' if v > 1 else 'down'} {abs(v-1)*100:.1f}%"
    for v in d["odds ratio (e^k)"]
])
show(table.sort_values("coefficient (log-odds)", key=abs, ascending=False))
featurecoefficient (log-odds)odds ratio (e^k)meaning
maydon_m2-0.9450.389up 1 std -> odds down 61.1%
xonalar-0.1750.84up 1 std -> odds down 16.0%
qavat0.0861.09up 1 std -> odds up 9.0%
metro_km0.0411.042up 1 std -> odds up 4.2%
qavatlar_soni-0.0280.972up 1 std -> odds down 2.8%
mebel-0.0210.979up 1 std -> odds down 2.1%

Odds are p / (1-p). If the probability is 0.75, the odds are 3 ("three to one").

exp(coefficient) is the multiplier on the odds. Above 1, the chances go up; below 1, they go down.

python8 lines
print("How odds and probability are related:")
print(f"{'probability':>12} {'odds':>10} {'log-odds':>11}")
print("-" * 36)
for p in [0.1, 0.25, 0.5, 0.75, 0.9]:
    odds = p / (1 - p)
    print(f"{p:>12} {odds:>10.3f} {np.log(odds):>11.3f}")
print()
print("At p = 0.5 the odds = 1 and the log-odds = 0 -> the centre of the sigmoid")
Output
How odds and probability are related:
 probability       odds    log-odds
------------------------------------
         0.1      0.111      -2.197
        0.25      0.333      -1.099
         0.5      1.000       0.000
        0.75      3.000       1.099
         0.9      9.000       2.197

At p = 0.5 the odds = 1 and the log-odds = 0 -> the centre of the sigmoid

In logistic regression a coefficient does NOT change the probability LINEARLY

"The coefficient is 0.3, so the probability rises by 30%": wrong.

The effect on the probability depends on where you start. Starting from p = 0.5 it moves a lot; starting from p = 0.95 it barely moves, because the sigmoid is flat there.

That's why the odds ratio is used: it's the same multiplier at any point.

Multiclass: when there are three classes

In the Chapter 43 exercise you work with penguins. Logistic regression handles three classes too, in two ways.

python26 lines
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.model_selection import cross_val_score

pg = pd.read_csv(DATA / "penguins.csv")
pnum = ["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"]
pcat = ["island", "sex"]
Xp, yp = pg[pnum + pcat], pg["species"]

def p_pipe(**kw):
    return Pipeline([
        ("t", ColumnTransformer([
            ("num", Pipeline([("i", SimpleImputer(strategy="median")), ("sc", StandardScaler())]), pnum),
            ("cat", Pipeline([("i", SimpleImputer(strategy="most_frequent")),
                              ("o", OneHotEncoder(handle_unknown="ignore"))]), pcat),
        ])),
        ("m", LogisticRegression(max_iter=3000, **kw)),
    ])

for name, kw in [("multinomial (softmax)", {}), ("one-vs-rest", {"multi_class": "ovr"})]:
    try:
        bb = cross_val_score(p_pipe(**kw), Xp, yp, cv=5, scoring="f1_macro")
        print(f"  {name:24s} macro F1 = {bb.mean():.4f} ±{bb.std():.4f}")
    except Exception as e:
        print(f"  {name:24s} {type(e).__name__}")
Output
  multinomial (softmax)    macro F1 = 0.9939 ±0.0076
  one-vs-rest              macro F1 = 0.9939 ±0.0076
python10 lines
mp = p_pipe().fit(Xp, yp)
print("Classes:", mp.named_steps["m"].classes_.tolist())
print("Coefficient shape:", mp.named_steps["m"].coef_.shape,
      "-> a separate set of coefficients for EACH CLASS")
print()
e = mp.predict_proba(Xp.head(4))
print("Probabilities for the first 4 penguins (each row sums to 1):")
print(pd.DataFrame(e.round(4), columns=mp.named_steps["m"].classes_).to_string(index=False))
print()
print("Row sums:", e.sum(axis=1).round(6).tolist())
Output
Classes: ['Adelie', 'Chinstrap', 'Gentoo']
Coefficient shape: (3, 9) -> a separate set of coefficients for EACH CLASS

Probabilities for the first 4 penguins (each row sums to 1):
 Adelie  Chinstrap  Gentoo
 0.9995     0.0004  0.0001
 0.9911     0.0066  0.0023
 0.9742     0.0234  0.0024
 0.8871     0.0505  0.0625

Row sums: [1.0, 1.0, 1.0, 1.0]

Softmax gives each class a probability, and they add up to exactly 1. Current sklearn versions use it by default.

The threshold: back to Chapter 9

Logistic regression gives a probability. predict() applies the 0.5 threshold. In Chapter 9 we moved that threshold.

python12 lines
from sklearn.metrics import precision_score, recall_score, f1_score, accuracy_score

proba = pipe.predict_proba(X_te)[:, 1]
print(f"{'threshold':>9} {'precision':>11} {'recall':>8} {'F1':>7}")
print("-" * 38)
for t in [0.3, 0.5, 0.7]:
    bb = (proba >= t).astype(int)
    print(f"{t:>9} {precision_score(y_te, bb, zero_division=0):>11.4f} "
          f"{recall_score(y_te, bb, zero_division=0):>8.4f} {f1_score(y_te, bb, zero_division=0):>7.4f}")
print()
print("That's why logistic regression is handy in practice: it gives a probability,")
print("and you choose the threshold to match the business need (Chapter 9).")
Output
threshold   precision   recall      F1
--------------------------------------
      0.3      0.6146   0.9725  0.7532
      0.5      0.7626   0.8297  0.7947
      0.7      0.8554   0.3901  0.5358

That's why logistic regression is handy in practice: it gives a probability,
and you choose the threshold to match the business need (Chapter 9).

When to choose logistic regression

python9 lines
show(pd.DataFrame([
    {"Strength": "Gives a probability", "Why it matters": "You can move the threshold (Chapter 9)"},
    {"Strength": "Explainable", "Why it matters": "The odds ratio: banks and medicine demand it"},
    {"Strength": "Fast and cheap", "Why it matters": "Works even with millions of rows"},
    {"Strength": "Little overfitting", "Why it matters": "A simple model, few parameters"},
    {"Weakness": "ONLY a linear boundary", "Why it matters": "Can't see complex patterns -> a tree (Chapter 18)"},
    {"Weakness": "Needs scaling", "Why it matters": "For the gradient and regularisation"},
    {"Weakness": "Doesn't create features itself", "Why it matters": "Needs manual feature engineering (Chapter 23)"},
]))
StrengthWhy it mattersWeakness
Gives a probabilityYou can move the threshold (Chapter 9)NaN
ExplainableThe odds ratio: banks and medicine demand itNaN
Fast and cheapWorks even with millions of rowsNaN
Little overfittingA simple model, few parametersNaN
NaNCan't see complex patterns -> a tree (Chapter 18)ONLY a linear boundary
NaNFor the gradient and regularisationNeeds scaling
NaNNeeds manual feature engineering (Chapter 23)Doesn't create features itself

Let's see the linear-boundary limitation; it's the bridge to Chapter 18:

python17 lines
from sklearn.tree import DecisionTreeClassifier

# an artificial "ring" pattern: no straight line can separate it
r = np.random.default_rng(42)
n = 600
angle = r.uniform(0, 2 * np.pi, n)
radius = np.where(r.random(n) < 0.5, r.normal(1, .25, n), r.normal(3, .3, n))
Xh = np.c_[radius * np.cos(angle), radius * np.sin(angle)]
yh = (radius > 2).astype(int)

for name, m in [("Logistic Regression", LogisticRegression()),
                ("Decision Tree", DecisionTreeClassifier(max_depth=6, random_state=42))]:
    bb = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", m)]), Xh, yh, cv=5)
    print(f"  {name:22s} accuracy = {bb.mean():.4f} ±{bb.std():.4f}")
print()
print("On ring-shaped data a linear boundary doesn't work.")
print("In Chapter 18 we see how a tree catches a pattern like this.")
Output
  Logistic Regression    accuracy = 0.5350 ±0.0220
  Decision Tree          accuracy = 0.9883 ±0.0085

On ring-shaped data a linear boundary doesn't work.
In Chapter 18 we see how a tree catches a pattern like this.

What we learned today

python9 lines
show(pd.DataFrame([
    {"Concept": "The problem", "In one sentence": "A linear model goes outside 0-1"},
    {"Concept": "Sigmoid", "In one sentence": "Squeezes any number into [0,1]"},
    {"Concept": "The model", "In one sentence": "sigmoid(x @ w + b): the Chapter 12 operation + one function"},
    {"Concept": "Log loss", "In one sentence": "Punishes confident mistakes hard; makes the bowl convex"},
    {"Concept": "Coefficient", "In one sentence": "log-odds; exp(k) = the odds multiplier"},
    {"Concept": "Multiclass", "In one sentence": "Softmax: a probability per class, summing to 1"},
    {"Concept": "Its limitation", "In one sentence": "Only a linear boundary: it can't separate a ring"},
]))
ConceptIn one sentence
The problemA linear model goes outside 0-1
SigmoidSqueezes any number into [0,1]
The modelsigmoid(x @ w + b): the Chapter 12 operation + one function
Log lossPunishes confident mistakes hard; makes the bowl convex
Coefficientlog-odds; exp(k) = the odds multiplier
MulticlassSoftmax: a probability per class, summing to 1
Its limitationOnly a linear boundary: it can't separate a ring

Next chapter: in Chapter 15 the coefficients jumped from split to split. We fix that.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents