Logistic regression
Predict probabilities for yes/no outcomes.
Create a free account Try part of it here
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
- A forecast, not a verdict
- Training: log loss and gradient descent
- Reading the coefficients
- The same model in scikit-learn
- 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 .
You've met these symbols before. The bold is the list of weights. The bold is one customer's features. The dot means multiply in pairs, then add up. Finally, 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.
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 , the Greek letter sigma.
Here is the probability of "yes". The letter 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 | −4 | −2 | 0 | 2 | 4 |
|---|---|---|---|---|---|
| probability | 0.018 | 0.119 | 0.5 | 0.881 | 0.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 + ) ≈ 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 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.
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 : 1 if the customer left, 0 if they stayed.
- If the customer left, the loss is .
- If the customer stayed, the loss is .
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 . If they stayed, it's . 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:
Take the symbols one at a time:
- is the log loss.
- is its gradient. The upside-down triangle is called nabla. The gradient holds one number per weight, saying which way is uphill.
- is the number of customers.
- is the table of features, one row per customer.
- is that table flipped, from the Phase 2 lesson on transpose.
- is the list of predicted probabilities.
- 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 . The chain rule splits the slope of the loss into two parts. First, how the loss changes with . Second, how changes with .
- The loss changes with at a rate of .
- The sigmoid's slope is .
Multiply them and most of it cancels. The curly below means "the slope of", from the Phase 2 lesson on partial derivatives:
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 .
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.
p, y, x = 0.5, 1, 2 w = 0.0 w -= 0.1 * (p - y) * x # learning rate 0.1 print(w)
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 : 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 that the model adds up is the log of the odds:
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 . Then the chance of "no" is:
Divide by . The bottoms are the same, so they cancel, leaving .
Read it as: the odds are 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 multiplies the odds by:
In plain words: raise 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: . 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 . 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.
p = 0.9 print(round(p / (1 - p), 1))
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 , which means .
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.
Train the model, then slide the threshold. Training changes what it knows; the threshold only changes how cautious it is.
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: 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))
After 5,000 small steps, the weight is 0.83 and the bias is −2.89. The odds ratio is , 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
Try a quiz question
What does logistic regression output before thresholding?