Classification
In short: Classification predicts a class, such as “will buy again” or not; logistic regression gives a probability, and the threshold trades precision for recall depending on which mistakes cost more.
Problem
“Will the customer buy again?”
Marketing wants to send a discount coupon to the customers who would otherwise stop buying. A coupon for everyone costs a lot, and most customers come back anyway. The question is: to whom?
Let’s go back in time. Stand on 1 July 2026: this is the cutoff. For each customer you know their orders up to that date. How many, how many days ago the last one was, how big the average basket is. Whether they order again you learn from July to the end of October:
Before 1 July, 1,279 customers bought something. After it, 72.6% of them ordered again. The lost column is 1 for the rest: they ordered nothing for 4 months. These are the people the coupon is for.
More precisely, if you are curious
Here we count every order, the cancelled ones too: the question is whether the customer comes, not how much revenue they bring. basket is the average value of their basket. ~people.index.isin(...) is the mask “the customer is missing after the cutoff”: ~ flips True and False.
Try it
How much comes free?
Store in lost_share what share of the customers is lost, and in baseline_acc how often the rule “everyone comes back” is right, without any model.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
lost_share = people['lost'].mean()
baseline_acc = (people['lost'] == 0).mean()
print(round(lost_share, 3), round(baseline_acc, 3))people['lost'].mean() gives 0.274: 27.4% are lost. The rule “everyone comes back” is wrong exactly about them and right about the rest: 1 − 0.274 = 0.726. This is the baseline from the lesson on the workflow. Any model for this question has to be above 72.6%, or you do not need it.
The idea
Classification: the answer is a class
In regression the prediction is a number: revenue for a day. Here the answer is “yes” or “no”: is the customer lost. Such a task is classification, and the possible answers are classes. The path is the same: features X, label y, split, fit, predict.
The first model is logistic regression (LogisticRegression). The name is misleading: it classifies. Like linear regression, it adds up the features with weights, but then it squeezes the sum into a chance between 0 and 1. Against it we put DummyClassifier, which always says the most common class:
The rule “everyone comes back” is right about 72.5% of the test customers. The model is right about 72.2%: less than the rule. Accuracy (the share of right answers) lies here. When about one customer in four is lost, “everyone stays” is almost always true.
More precisely, if you are curious
stratify=y splits so that the share of lost customers is the same in training and in the test: 27.5%. Without it, with unequal classes, the test may by chance get more or fewer lost customers. Here it is fair to split customers at random: each customer’s features are from before the cutoff, and the label is from after it. max_iter=1000 gives the model more steps to find the weights: the features are on different scales (orders up to 77, days up to 240).
The idea
The confusion matrix
Accuracy is one number for all errors. But the errors are not the same. A missed lost customer is lost income. A coupon for a customer who would have come back is a discount given away. The table that counts them separately is called the confusion matrix:
The rows are the truth, the columns are what the model says. Of 88 lost customers the model points to 1. It misses the other 87. That is why it is as accurate as the rule: it too almost always says “stays”.
The idea
Precision and recall: who gets a coupon
Two numbers describe the coupons better than accuracy:
- precision: of the customers with a coupon, how many really would have left. Low means many discounts given away;
- recall: of everyone who would have left, how many get a coupon. Low means many missed customers.
The model gives 3 coupons. One goes to a lost customer: precision is 0.333. Of the 88 lost it catches 1: recall is 0.011. Such a model does not help marketing, whatever its accuracy.
The idea
The chance and the threshold
predict is not all the model knows. predict_proba gives a chance for each class. The columns follow the order of model.classes_: the first is for 0 (stays), the second for 1 (lost):
The highest chance of loss in the test is 0.56. predict gives a coupon only if the chance is over 0.5, and there are 3 such customers. But 0.5 is not a law. The number above which you say “yes” is called the threshold, and you choose it. At a threshold of 0.3 there are 160 coupons:
Of 160 coupons, 63 go to lost customers: precision is 39.4%. Of the 88 lost you catch 63: recall is 71.6%. Accuracy drops, but now the model works. For comparison: 160 coupons to random customers would have a precision equal to the share of lost ones, 27.5%. The model ranks customers better than chance, though far from perfectly.
More precisely, if you are curious
Another model gives a similar picture. A decision tree with depth 3 is also exactly as accurate as the rule, and at a threshold of 0.3 it has a precision of 38.8% and a recall of 64.8%:
With these three features you cannot get much more. If you want a better model, look for features that say more about the customer: what products they buy, how regularly they order.
Try it
Count them yourself
At a threshold of 0.4, count fp (a coupon for a customer who stays) and fn (a lost customer without a coupon), then compute precision and recall from tp, fp and fn, without sklearn.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
X = people[['orders', 'days_since', 'basket']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= 0.4).astype(int)
truth = y_test.to_numpy()
tp = ((pred == 1) & (truth == 1)).sum()
fp = ((pred == 1) & (truth == 0)).sum()
fn = ((pred == 0) & (truth == 1)).sum()
precision = tp / (tp + fp)
recall = tp / (tp + fn)
print(tp, fp, fn, round(precision, 3), round(recall, 3))At a threshold of 0.4, tp = 37, fp = 44 and fn = 51. Precision = 37 / (37 + 44) = 0.457: you divide by everyone with a coupon. Recall = 37 / (37 + 51) = 0.42: you divide by all lost customers. If you swap them, you answer the other question.
The idea
The threshold trades one for the other
Each threshold gives different coupons. Here are all thresholds from 0.1 to 0.5:
At a threshold of 0.1 there are 262 coupons and you catch 96.6% of the lost customers, but precision is 32.4%: a little over the 27.5% of random coupons. At 0.4 there are 81 coupons and precision is 45.7%, but you miss more than half of the lost customers. At 0.5 there are only 3 coupons. No threshold is best on its own. The choice depends on the cost: what a coupon costs and what a lost customer costs.
Exercise
Marketing’s coupons
Marketing has 80 coupons for the customers in the test. Give them to the 80 with the highest chance of loss: proba.argsort() gives the positions from the smallest to the largest chance. Store in hits how many of them are really lost, in precision their share among the 80, and in recall what share of all lost customers in the test gets a coupon.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
X = people[['orders', 'days_since', 'basket']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
top = proba.argsort()[::-1][:80]
hits = y_test.to_numpy()[top].sum()
precision = hits / 80
recall = hits / y_test.sum()
print(hits, round(precision, 3), round(recall, 3))proba.argsort()[::-1][:80] gives the positions of the 80 with the highest chance. Of them, 36 are lost: precision 0.45, recall 36 / 88 = 0.409. 80 random coupons would have found about 22 lost customers. With argsort() without reversing, you give the coupons to the safest customers, and with the column [:, 0] you sort by the chance that the customer stays.
Marketing says: “We want to reach at least 80% of the lost customers.” Find in best the highest threshold from 0.05 to 0.5 where recall is at least 0.8, and in best_precision the precision at it.
Solution
import pandas as pd
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
X = people[['orders', 'days_since', 'basket']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
import numpy as np
from sklearn.metrics import precision_score, recall_score
best = None
for t in np.arange(0.05, 0.51, 0.05).round(2):
pred = (proba >= t).astype(int)
if recall_score(y_test, pred) >= 0.8:
best = t
pred = (proba >= best).astype(int)
best_precision = precision_score(y_test, pred)
print(best, round(best_precision, 3))Recall is at least 0.8 up to a threshold of 0.25. There are 193 coupons, recall is 86.4% and precision 39.4%. A lower threshold also reaches 80%, but with more needless coupons. That is why you look for the highest threshold that still meets the goal.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Dates and time series · The first chart · Models: learning from examples · Linear regression
- Continues in: Data leakage · Cross-validation
- Tasks on this topic: Models with scikit-learn