The most dangerous number in ML is a high accuracy
You now have a clean, stratified split from b2. Time to train a real model on it and score it honestly. We will build a logistic regression classifier for converted, and in the same breath confront why its headline accuracy is a mirage. When only 3.2% of sessions convert, guessing "no" every single time is right 96.8% of the time - so any metric that rewards that behaviour is measuring the wrong thing. The whole point of today is to feel that gap on live numbers.
Logistic regression, start to finish 6 min live
Despite the name, logistic regression is a classifier. It takes a linear combination of the features - a raw score - and squeezes it through the logistic (sigmoid) curve into a probability between 0 and 1. That probability is what predict_proba returns. predict then applies a threshold - 0.5 by default - to turn the probability into a yes/no. The threshold is a choice, not a law, and choosing it well is the b7 story.
Livepredict vs predict_proba3 min▶
Two methods, two different answers - know when to reach for each:
model.predict(X)- returns the hard class, 0 or 1, after applying the 0.5 threshold. Good for a final decision.model.predict_proba(X)- returns the probability of each class. Column 1 is P(converts). This is the richer output: it lets you rank visitors by likelihood and choose your own threshold later.
predict, you never see that - which is how the accuracy paradox catches people.
Self-studythe default 0.5 threshold is arbitrary3 min▶
The 0.5 cutoff is a convenient default, not a business decision. When conversion is rare and a buyer is worth far more than the cost of a wasted email, you often want to catch more buyers - so you lower the threshold, accepting more false positives to raise recall:
proba = clf.predict_proba(X_te)[:, 1] # P(convert) for each row preds_default = (proba >= 0.5).astype(int) # default - catches almost no one preds_aggressive = (proba >= 0.10).astype(int) # lower bar - catches more buyers
Which cutoff is right depends on the cost of a false positive versus a false negative - a business call, formalised with precision and recall in b7. For now, just know 0.5 is a starting point, not the answer.
Train the classifier, then break the accuracy illusion 22 min · everyone builds
We reload Lumen, reuse the stratified split from b2, scale the numeric features on train only, fit logistic regression, and read three numbers in order: accuracy (looks great), the dumb-baseline accuracy (identical - the paradox), and ROC-AUC (the honest signal). Precision and recall come in b7.
Reload, split, and scale numeric features. Start from the parquet, take three numeric features to keep it clean, and scale on train only - the b2 discipline.
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
df = pd.read_parquet("lumen_sessions.parquet")
num_cols = ["prior_30d_spend", "pages_viewed", "session_hour"]
X = df[num_cols]
y = df["converted"]
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
scaler = StandardScaler().fit(X_tr) # fit on TRAIN only
X_tr_s = scaler.transform(X_tr)
X_te_s = scaler.transform(X_te)
Fit, predict, and score accuracy. Same fit/predict API as b1, now with a proper split and a real metric.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
clf = LogisticRegression(max_iter=1000).fit(X_tr_s, y_tr)
preds = clf.predict(X_te_s)
print("accuracy: %.3f" % accuracy_score(y_te, preds)) # ~ 0.968
~96.8% accuracy. On any other day that is a victory. Hold that feeling - the next step takes it apart.
Compare to the dumb baseline - the accuracy paradox. A model that always predicts 0 (no conversion) scores the same, because 96.8% of sessions really are "no".
from sklearn.dummy import DummyClassifier
dumb = DummyClassifier(strategy="most_frequent").fit(X_tr_s, y_tr)
print("always-no accuracy: %.3f" % dumb.score(X_te_s, y_te)) # ~ 0.968
print("buyers our model flagged:", int(preds.sum())) # very few - near zero
print("buyers in the test set :", int(y_te.sum())) # ~320
Equal accuracy, but the "always no" model catches zero buyers - and our logistic model, at the 0.5 threshold, catches almost none either. That is the accuracy paradox: on imbalanced data, accuracy rewards predicting the majority class and stays silent about the rare class you actually care about. Accuracy is the wrong score here.
ROC-AUC - the honest signal. Score the probabilities, not the hard 0/1. AUC asks: does the model rank a random buyer above a random non-buyer?
from sklearn.metrics import roc_auc_score
proba = clf.predict_proba(X_te_s)[:, 1] # P(convert)
print("ROC-AUC: %.3f" % roc_auc_score(y_te, proba)) # ~ 0.71
A growth team once shipped a churn model with "97% accuracy!" on the slide. It flagged not a single at-risk customer - because 97% of customers were not churning, and the model had simply learned to say "fine" every time. The metric was true and completely useless. ROC-AUC of 0.71 is a humbler, honest headline: the model ranks buyers above non-buyers meaningfully better than a coin flip, which is a real if modest signal you can build on - not a fantasy you will have to walk back.
This week ◐ 35 min total
- Look at the probabilities. Print a histogram of
clf.predict_proba(X_te)[:, 1]. Notice how most mass sits well below 0.5 - that is why the 0.5 threshold catches so few buyers. - Count the catches. How many actual buyers does the 0.5 threshold flag as "yes"? Compare to the ~320 buyers in the test set. Then lower the threshold to 0.10 and count again.
- Build the baseline yourself. Without
DummyClassifier, compute the always-0 accuracy by hand:1 - y_te.mean(). Confirm it lands at ~96.8% and matches the model. Write one sentence on why that equality is the whole lesson.
Three questions before you go 🎯 ◐ 90 seconds
1 · Your classifier hits ~96.8% accuracy. Why is that misleading?
With a 3.2% positive rate, guessing "no" every time is right 96.8% of the time. The model ties that baseline and catches near-zero buyers, so accuracy rewards the majority class and hides the failure on the rare class. That is the accuracy paradox.
2 · What does predict_proba return that predict does not?
predict_proba gives the probability of each class; column 1 is P(convert). predict collapses that to 0/1 at the 0.5 threshold. The probabilities carry the ranking signal that hard labels discard - crucial on imbalanced data.
3 · What does ROC-AUC ~0.71 actually tell you?
ROC-AUC is the probability the model scores a random buyer higher than a random non-buyer. 0.5 is a coin flip, 1.0 is perfect ranking; 0.71 is a genuine, if modest, signal - and unlike accuracy it is not fooled by the class imbalance. It is not an accuracy or a recall.
What this session covers
This builder session trains logistic regression end to end on Lumen and confronts the accuracy paradox with ROC-AUC. The full confusion matrix, precision, recall, F1, and threshold tuning get their own session in b7.
Builder Session 3 cheat sheet · pin this
LogisticRegression(max_iter=1000).fit(X, y).predict gives hard 0/1 at the 0.5 threshold; predict_proba(X)[:, 1] gives P(convert) - the ranking signal.roc_auc_score the probabilities, not the 0/1 predictions - it grades ranking, not the arbitrary cutoff.