Index Ai Academy
Phase 3 · Machine learning · Models

Logistic regression

Predict probabilities for yes/no outcomes.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Most business predictions are yes/no. Will this customer leave? Will this lead buy? Is this review negative? Logistic regression answers with a probability, like a forecast's "70% chance of rain". It trains fast, and you can read what each of its weights means. That makes it the standard first model for any yes/no problem. Its structure is also exactly one neuron of a neural network: a weighted sum, squashed into a probability, trained with log loss.

What you'll be able to do

  • Turn a linear score into a probability with the sigmoid
  • Train logistic regression with log loss and gradient descent, from scratch
  • Read coefficients as changes in log-odds and odds ratios
  • Choose a decision threshold for turning probabilities into actions

What it covers

  1. A forecast, not a verdict
  2. Training: log loss and gradient descent
  3. Reading the coefficients
  4. The same model in scikit-learn
  5. Worked example: train a tiny churn model

A forecast, not a verdict

A weather forecaster doesn't say "rain" or "no rain". She says "a 70% chance of rain". Then you decide whether to take an umbrella.

Logistic regression does the same for yes/no questions. Will this customer leave? Will this lead buy? The model doesn't answer yes or no. It gives a probability: a number between 0 and 1. What you do with it comes later.

Despite the name, it's a model for yes/no answers, called classification. It doesn't predict amounts like a price. The "regression" part comes from the weighted sum inside it.

It works in two steps.

Step 1: add up a score

Start the same way as linear regression. Multiply each feature by its weight, add the results, then add a starting amount. The result is a score called the logit, written zz.

z=w⋅x+bz = \mathbf{w} \cdot \mathbf{x} + b

You've met these symbols before. The bold w\mathbf{w} is the list of weights. The bold x\mathbf{x} is one customer's features. The dot means multiply in pairs, then add up. Finally, bb is the bias, the starting amount. Linear regression called it the intercept.

In plain words: the score is each feature times its weight, all added up, plus a starting amount. A high score leans "yes". A low score leans "no".

The score can be any number: −7, 0.5 or 300. That can't be a probability. So step 2 squashes it.

Quick check
Why can't the score zz be used as a probability directly?
  1. It's always a whole number
  2. It can be any number, and a probability must be between 0 and 1
  3. It's always negative
  4. It takes too long to compute
Show the answer

It can be any number, and a probability must be between 0 and 1

A score of 300 means nothing as a chance, so the next step squashes it.

Step 2: squash it into a probability

The squashing is done by the sigmoid, the S-curve from the Phase 2 lesson on functions and graphs. Its name is written σ\sigma, the Greek letter sigma.

p=σ(z)=11+e−zp = \sigma(z) = \frac{1}{1 + e^{-z}}

Here pp is the probability of "yes". The letter ee is the number about 2.718 from the lesson on exponentials.

In plain words: feed in the score and get back a number between 0 and 1. Big positive scores land near 1. Big negative scores land near 0. A score of exactly 0 lands at 0.5.

score zz−4−2024
probability pp0.0180.1190.50.8810.982

Here's a worked example. A toy churn model uses two features: months since the last order, and orders this year. Its weights are 0.9 and −0.6, and its bias is −1. Take a customer who has been quiet for 3 months and placed 2 orders.

  • The score: 0.9 × 3 − 0.6 × 2 − 1 = 2.7 − 1.2 − 1 = 0.5.
  • The probability: 1 / (1 + e−0.5e^{-0.5}) ≈ 0.622.

So the model gives this customer about a 62% chance of leaving.

import math

weights = [0.9, -0.6]      # per month since last order, per order this year
features = [3, 2]          # 3 months quiet, 2 orders
b = -1.0                   # the bias

z = sum(w * x for w, x in zip(weights, features)) + b
p = 1 / (1 + math.exp(-z))
print(round(z, 2), round(p, 3))
It runs right here in your browser, with nothing to install.

It prints 0.5 and 0.622, the same as by hand.

The decision boundary

A customer with a score of exactly 0 gets exactly 0.5. They sit on the fence. All the fence-sitters together form the decision boundary. On one side the model leans "yes". On the other side it leans "no".

The score is a weighted sum, so the boundary is straight. With two features it's a straight line. With three it's a flat surface, called a plane. With more, it's the same kind of flat cut. That's a real limit: logistic regression can only split customers with a straight cut.

Quick check
A logistic regression gives a customer z = 0. What churn probability does that mean?
  1. It depends on the features
  2. 0.5
  3. 1
  4. 0
Show the answer

0.5

σ(0) = 1 / (1 + 1) = 0.5, because e to the power 0 is 1. The customer sits exactly on the decision boundary.

Training: log loss and gradient descent

You have the model's shape. Training finds the weights. Linear regression had an exact recipe for its best weights. Logistic regression has none. So you use gradient descent, from earlier in this phase: start somewhere, then take many small steps downhill on the loss.

The loss: log loss

The loss is log loss, from the lesson on loss functions. It scores each probability against what really happened. Write the truth as yy: 1 if the customer left, 0 if they stayed.

  • If the customer left, the loss is −log⁡p-\log p.
  • If the customer stayed, the loss is −log⁡(1−p)-\log(1 - p).

Here log is the natural log. The log loss for a whole dataset is the average over all customers.

In plain words: the loss is small when you gave a high probability to what really happened. It's large when you were confident and wrong.

Say the model gives a customer 0.9. If they left, the loss is −log⁡0.9≈0.105-\log 0.9 \approx 0.105. If they stayed, it's −log⁡0.1≈2.303-\log 0.1 \approx 2.303. Being sure and wrong costs about 22 times more.

import math

p = 0.9                              # the model's churn probability
print(round(-math.log(p), 3))        # loss if the customer left
print(round(-math.log(1 - p), 3))    # loss if they stayed

It prints 0.105 and then 2.303.

The step: a familiar gradient

Each training step nudges every weight. The gradient says which way and how far. For logistic regression it has a tidy form:

∇wL=1nX⊤(p−y)\nabla_{\mathbf{w}} L = \frac{1}{n} X^\top (\mathbf{p} - \mathbf{y})

Take the symbols one at a time:

  • LL is the log loss.
  • ∇wL\nabla_{\mathbf{w}} L is its gradient. The upside-down triangle is called nabla. The gradient holds one number per weight, saying which way is uphill.
  • nn is the number of customers.
  • XX is the table of features, one row per customer.
  • X⊤X^\top is that table flipped, from the Phase 2 lesson on transpose.
  • p\mathbf{p} is the list of predicted probabilities.
  • y\mathbf{y} is the list of true answers.

In plain words: for each customer, work out the miss: predicted probability minus truth. For each weight, multiply every customer's miss by their value of that feature, then average. That says which way is uphill for that weight, so you step the other way.

It has the same shape as linear regression's gradient: the miss, weighted by the features. (The softmax and cross-entropy gradient in Phase 2 was the version of this for many classes.)

Here's one step by hand. There are two customers and one feature, plus a column of ones for the bias. Customer A has feature 1 and left. Customer B has feature −1 and stayed. All weights start at 0.

  • Both scores are 0, so both probabilities are 0.5.
  • The misses: A gets 0.5 − 1 = −0.5. B gets 0.5 − 0 = 0.5.
  • The feature's gradient: (−0.5 × 1 + 0.5 × −1) / 2 = −0.5.
  • The bias's gradient: its "feature" is always 1, so (−0.5 + 0.5) / 2 = 0.
  • Step with a learning rate of 0.5: the feature's weight becomes 0 − 0.5 × (−0.5) = 0.25.

The weight went up. Now a higher feature means a higher chance of leaving. That fits the data: the customer who left had the higher feature.

import numpy as np

X = np.array([[1.0, 1.0], [-1.0, 1.0]])   # feature, then a column of ones for the bias
y = np.array([1, 0])                      # A left, B stayed
w = np.zeros(2)

p = 1 / (1 + np.exp(-(X @ w)))            # both 0.5
gradient = X.T @ (p - y) / len(y)
w -= 0.5 * gradient                       # learning rate 0.5
print(gradient, w)

It prints the gradient, −0.5 and 0, then the new weights, 0.25 and 0. In NumPy, X.T is the flipped table and @ multiplies tables.

Go deeper Why the gradient is so simple

Look at one customer and their score zz. The chain rule splits the slope of the loss into two parts. First, how the loss changes with pp. Second, how pp changes with zz.

  • The loss changes with pp at a rate of −yp+1−y1−p-\frac{y}{p} + \frac{1 - y}{1 - p}.
  • The sigmoid's slope is p(1−p)p(1 - p).

Multiply them and most of it cancels. The curly ∂\partial below means "the slope of", from the Phase 2 lesson on partial derivatives:

∂L∂z=p−y\frac{\partial L}{\partial z} = p - y

Read it as: the slope of one customer's loss, with respect to their score, is the predicted probability minus the truth.

The score is a weighted sum, so a weight's effect on the score is its feature. That multiplies in the feature. Averaging over customers gives the formula above. You can check the result with small numbers. Nudge the score a tiny amount and see how much the loss moves:

import math

y, h = 1, 0.000001          # a customer who left; a tiny nudge

def loss(z):
    p = 1 / (1 + math.exp(-z))
    return -(y * math.log(p) + (1 - y) * math.log(1 - p))

z = 0.5
slope = (loss(z + h) - loss(z)) / h
p = 1 / (1 + math.exp(-z))
print(round(slope, 4), round(p - y, 4))

Both numbers come out as −0.3775. The measured slope matches p−yp - y.

Training on real customers

Now the full loop on the practice shop's 600 customers. It uses five features. Each one is standardized, from the data preparation lesson. That shifts and scales it so its average is 0 and its typical spread, the standard deviation, is 1. A column of ones carries the bias. The loop takes 2,000 steps with a learning rate of 0.5.

import numpy as np
import pandas as pd
import requests

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["orders_last_year", "days_since_last_order", "support_tickets", "trail_club", "tenure_months"]
X_raw = df[features].to_numpy(dtype=float)
X = np.column_stack([(X_raw - X_raw.mean(axis=0)) / X_raw.std(axis=0), np.ones(len(df))])
y = df["churned"].to_numpy()

sigmoid = lambda z: 1 / (1 + np.exp(-z))
w = np.zeros(X.shape[1])
for step in range(2000):
    p = sigmoid(X @ w)
    w -= 0.5 * X.T @ (p - y) / len(y)

p = sigmoid(X @ w)
log_loss = -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))
print("log loss:", round(log_loss, 3), " accuracy at 0.5:", round(np.mean((p > 0.5) == y), 3))
for name, coef in zip(features + ["bias"], w):
    print(f"{name:<22} {coef:+.2f}")

It prints a log loss of 0.374. Then comes an accuracy of 0.838: with a cut-off of 0.5, the model labels about 84% of customers correctly.

Be careful with that number. Only about 19% of these customers churned. So guessing "stays" for everyone would already score about 81%. The evaluation lessons later in this phase show better ways to judge a yes/no model.

Below that, it prints one weight per feature, and the bias:

  • orders_last_year, −0.82. More orders, less churn.
  • days_since_last_order, +0.72. The longer since the last order, the more churn.
  • support_tickets, +0.34. More complaints, more churn.
  • trail_club, −0.38. Loyalty program members churn less.
  • tenure_months, −0.33. Longer-standing customers churn less.
  • bias, −1.93. The score of a customer who is average on every feature.
Quick check
One customer left (y = 1). The model gave them 0.5, their feature is 2, and the weight starts at 0. What does this print?
p, y, x = 0.5, 1, 2
w = 0.0
w -= 0.1 * (p - y) * x      # learning rate 0.1
print(w)
  1. -0.1
  2. 1.0
  3. 0.05
  4. 0.1
Show the answer

0.1

The miss is 0.5 − 1 = −0.5. Times the feature 2, the gradient is −1. Stepping against it: 0 − 0.1 × (−1) = 0.1. The weight rises, so this feature now pushes toward 'left'.

Reading the coefficients

The weights are also called coefficients. To read them, you need one idea from betting: odds.

Odds

Odds of "3 to 1" mean something is three times as likely to happen as not. The odds of an event are p/(1−p)p / (1 - p): the chance it happens, divided by the chance it doesn't.

In plain words: odds compare "yes" with "no". Odds of 3 mean "yes" is three times as likely as "no". Odds of 1 mean an even split.

With small numbers: a churn probability of 0.75 gives odds of 0.75 / 0.25 = 3. That's 3 to 1.

Log-odds: what the score really is

Here is the key fact. The score zz that the model adds up is the log of the odds:

log⁡p1−p=w⋅x+b\log \frac{p}{1 - p} = \mathbf{w} \cdot \mathbf{x} + b

In plain words: take the odds, then take their natural log. You get back the score the model added up. That's why the score is also called the log-odds.

Try it with a score of 1.1. The sigmoid gives a probability of 0.75. The odds are 3.0, and the log of 3 is about 1.1, the score you started with.

import math

z = 1.1
p = 1 / (1 + math.exp(-z))
odds = p / (1 - p)
print(round(p, 2), round(odds, 2), round(math.log(odds), 2))

It prints 0.75, 3.0 and 1.1.

Go deeper Why the log-odds undo the sigmoid

Start from p=11+e−zp = \frac{1}{1 + e^{-z}}. Then the chance of "no" is:

1−p=e−z1+e−z1 - p = \frac{e^{-z}}{1 + e^{-z}}

Divide pp by 1−p1 - p. The bottoms are the same, so they cancel, leaving 1e−z=ez\frac{1}{e^{-z}} = e^{z}.

Read it as: the odds are ee raised to the power of the score. Taking the natural log undoes the power, so the log of the odds is the score.

The cell above checks it with numbers: a score of 1.1 gives odds of about 3.0, and the log of 3.0 is 1.1.

What one coefficient does

Each coefficient adds to the log-odds. Adding to a log is the same as multiplying the thing inside it. You saw this in the lesson on exponentials: logs turn products into sums. So when a feature goes up by 1, its coefficient ww multiplies the odds by:

odds ratio=ew\text{odds ratio} = e^{w}

In plain words: raise ee to the coefficient. That's how many times the odds grow when the feature goes up by one unit, with the other features held fixed.

This multiplier is called the odds ratio. A positive coefficient gives an odds ratio above 1, so the odds grow. A negative one gives an odds ratio below 1, so the odds shrink. A coefficient of 0 gives exactly 1: no change.

Here's a worked example from the shop. The features were standardized, so "one unit" means one standard deviation. For days since the last order, that's about 94 days, roughly three months. Its coefficient is about +0.72.

  • The odds ratio: e0.72≈2.05e^{0.72} \approx 2.05. Three more quiet months roughly double the odds.
  • Take a customer with a 20% churn chance. Their odds are 0.2 / 0.8 = 0.25, or 1 to 4.
  • Three more quiet months: 0.25 × 2.05 ≈ 0.51.
  • Turn odds back into a probability with odds / (1 + odds): 0.51 / 1.51 ≈ 0.34.

So the churn chance goes from 20% to about 34%. The odds doubled, but the probability didn't. A coefficient changes the odds by a fixed multiple. How much the probability moves depends on where it started.

import math

odds = 0.25                          # a 20% churn chance: 1 to 4
new_odds = odds * math.exp(0.72)     # three more quiet months
print(round(math.exp(0.72), 2), round(new_odds, 2), round(new_odds / (1 + new_odds), 2))

It prints 2.05, 0.51 and 0.34.

The negative coefficients work the same way in reverse. Orders have about −0.82, and e−0.82≈0.44e^{-0.82} \approx 0.44. So one standard deviation more orders cuts the odds of churning to less than half. Membership and tenure lower the odds too.

Every sign matches common sense. That's a useful sanity check on any model. Comparing the sizes of these coefficients is fair only because the features were standardized first. On raw units, a weight per day and a weight per order can't be compared.

Quick check
A customer has a churn probability of 0.9. What does this print?
p = 0.9
print(round(p / (1 - p), 1))
  1. 0.9
  2. 1.1
  3. 10.0
  4. 9.0
Show the answer

9.0

The odds are p / (1 − p) = 0.9 / 0.1 = 9, which prints as 9.0. Leaving is nine times as likely as staying: 9 to 1.

The same model in scikit-learn

In practice you'd use scikit-learn rather than write the loop yourself. The code below joins two steps with make_pipeline. StandardScaler standardizes the features, then LogisticRegression trains the model. train_test_split holds back a quarter of the customers for testing. Setting stratify keeps the same share of churners in both parts.

import numpy as np
import pandas as pd
import requests
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["orders_last_year", "days_since_last_order", "support_tickets", "trail_club", "tenure_months"]
X_train, X_test, y_train, y_test = train_test_split(df[features], df["churned"], test_size=0.25, random_state=0, stratify=df["churned"])

model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
probs = model.predict_proba(X_test)[:, 1]          # column 1 = P(churn)
print("first five churn probabilities:", probs[:5].round(2))
for threshold in [0.5, 0.3, 0.2]:
    flagged = probs >= threshold
    caught = (flagged & (y_test == 1)).sum()
    print(f"threshold {threshold}: flag {flagged.sum():>3} customers, catching {caught} of {(y_test == 1).sum()} churners")

predict_proba returns one column per class. Column 0 is the chance of staying. Column 1 is the chance of churning. Each row adds up to 1. The shortcut predict turns these into labels with a cut-off of 0.5.

The numbers won't match the from-scratch model exactly. This one trains on only three quarters of the customers. scikit-learn also adds a small penalty by default that keeps weights from growing large. That's called regularization, and it comes later in this phase.

The loop at the end tries three cut-offs. A lower cut-off flags more customers. It never catches fewer of the real churners, and usually catches more. The price is that more of the flagged customers would have stayed anyway.

Choosing the threshold

The cut-off is called the threshold. Above it, you act. 0.5 is a default, not a law. The right threshold depends on what each mistake costs.

Say a retention offer costs $5, and a lost customer costs $200. Sending the offer pays off when the expected saving beats the cost. The expected saving is the churn probability times $200. So the offer is worth it when p×200>5p \times 200 > 5, which means p>5/200=0.025p > 5 / 200 = 0.025.

In plain words: act when the probability times what you'd save is bigger than what acting costs. The break-even threshold is the cost divided by the value.

offer_cost = 5         # dollars per offer
customer_value = 200   # dollars lost when a customer leaves
print(offer_cost / customer_value)

It prints 0.025, far below 0.5. Flagging more customers is worth it, even though many of them would have stayed anyway.

This is a simplification. It assumes the offer always keeps the customer. If it works only some of the time, the threshold goes up. The evaluation lessons later in this phase (precision, recall and ROC curves) give you the tools to pick a threshold deliberately.

Try it now

Train the model, then slide the threshold. Training changes what it knows; the threshold only changes how cautious it is.

Quick check
A coupon costs $10. A lost customer costs $100. If the coupon always keeps the customer, above what churn probability is it worth sending?
  1. 0.1
  2. 0.5
  3. 0.01
  4. 10
Show the answer

0.1

Send it when p × $100 > $10, so p > 10 / 100 = 0.1. That's well below the default 0.5.

Worked example

Worked example: train a tiny churn model

A coffee subscription has eight customers. For each one you know the months since their last order (0 to 7) and whether they left. Train logistic regression with gradient descent. Then read the weight as an odds ratio, look at each customer's probability, and find where the model switches from "stays" to "leaves".

import numpy as np

months = np.array([0, 1, 2, 3, 4, 5, 6, 7], dtype=float)   # months since last order
churned = np.array([0, 0, 1, 0, 0, 1, 1, 1])                # 1 = the customer left

X = np.column_stack([months, np.ones_like(months)])         # feature, then a column of ones
sigmoid = lambda z: 1 / (1 + np.exp(-z))

w = np.zeros(2)
for step in range(5000):
    p = sigmoid(X @ w)
    w -= 0.1 * X.T @ (p - churned) / len(churned)

print("weight:", round(w[0], 2), " bias:", round(w[1], 2))
print("odds ratio per month:", round(np.exp(w[0]), 2))
print("probabilities:", sigmoid(X @ w).round(2))
print("boundary at month:", round(-w[1] / w[0], 2))
In the lesson you can change this and run it.

After 5,000 small steps, the weight is 0.83 and the bias is −2.89. The odds ratio is e0.83≈2.29e^{0.83} \approx 2.29, so each extra quiet month multiplies the odds of leaving by about 2.3.

The probabilities climb from 0.05 for a customer who ordered this month to 0.95 at seven months. The boundary is where the score is 0: 0.83 × months − 2.89 = 0, so months = 2.89 / 0.83 ≈ 3.5. Below three and a half months the model leans "stays". Above it, it leans "leaves".

With a 0.5 cut-off, six of the eight customers are labeled correctly. The model misses the customer who left after 2 months (0.22) and the one who stayed after 4 months (0.6). A straight cut can't separate them, which is normal for real data.

Practice what you just read

  • 10 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does logistic regression output before thresholding?

  1. A probability for the positive class
  2. A class label, already decided
  3. A distance from the nearest example
  4. The log loss for that single row

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson