Skip to content

ROC, PR, threshold and imbalanced data

Chapter 9 12 code blocks 3013 words

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

The scene: precision is low, so do we retrain?

In Chapter 8 our model gave precision = 0.7476 and recall = 0.8462. Then the manager walks in:

"The ad budget got cut. When we label a listing 'goes fast', I want that to be right at least 85 percent of the time."

What do you do? Try another algorithm? Collect more data? Order a GPU?

No. You change one number, and you do not retrain the model at all.

That is what this chapter is about. It continues step 10 of the protocol.

Column names

The data is the Tashkent rental listings, and its column names are Uzbek. tuman district, 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), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.

The model does not really output 0 or 1

The first thing to understand: predict() gives you an answer, but the model computes a probability.

python26 lines
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X, y = df[num + ["tuman"]], 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)

pipe = Pipeline([
    ("prep", ColumnTransformer([
        ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                          ("scale", StandardScaler())]), num),
        ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
    ])),
    ("model", LogisticRegression(max_iter=2000)),
]).fit(X_tr, y_tr)

proba = pipe.predict_proba(X_te)[:, 1]
compare = pd.DataFrame({"probability": proba.round(3), "predict()": pipe.predict(X_te),
                        "truth": y_te.values}).head(8)
show(compare)
probabilitypredict()truth
0.3201
0.76711
0.6511
0.05600
0.83111
0.61511
0.59510
0.68210

There is a hidden rule inside predict(): if the probability is above 0.5, answer 1, otherwise 0.

0.5 is not a mathematical truth. It is a default value, and you are allowed to change it.

Moving the threshold

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

print(f"{'threshold':>9} {'precision':>10} {'recall':>8} {'F1':>7} {'accuracy':>9} {'yes count':>10}")
print("-" * 57)
for t in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
    b = (proba >= t).astype(int)
    print(f"{t:>9} {precision_score(y_te, b, zero_division=0):>10.4f} "
          f"{recall_score(y_te, b, zero_division=0):>8.4f} "
          f"{f1_score(y_te, b, zero_division=0):>7.4f} "
          f"{accuracy_score(y_te, b):>9.4f} {int(b.sum()):>10}")
Output
threshold  precision   recall      F1  accuracy  yes count
---------------------------------------------------------
      0.3     0.6181   0.9780  0.7574    0.6833        288
      0.4     0.6748   0.9121  0.7757    0.7333        246
      0.5     0.7476   0.8462  0.7938    0.7778        206
      0.6     0.8027   0.6484  0.7173    0.7417        147
      0.7     0.8370   0.4231  0.5620    0.6667         92
      0.8     0.9189   0.1868  0.3105    0.5806         37

Read the table. As the threshold rises, precision goes up and recall goes down. This is a fixed rule, not a coincidence.

The reason is simple. A higher threshold means the model says "yes" less often. Fewer yeses means it only says yes when it is most confident (precision rises), but it misses many real cases (recall falls).

The manager wants precision ≥ 0.85. Which threshold gets us there?

python12 lines
import numpy as np
needed = None
for t in np.arange(0.30, 0.96, 0.01):
    b = (proba >= t).astype(int)
    if b.sum() > 0 and precision_score(y_te, b, zero_division=0) >= 0.85:
        needed = t
        break
b = (proba >= needed).astype(int)
print(f"Lowest threshold with precision >= 0.85: {needed:.2f}")
print(f"  precision: {precision_score(y_te, b):.4f}")
print(f"  recall   : {recall_score(y_te, b):.4f}")
print(f"  times the model said yes: {int(b.sum())} (it was {int((proba >= 0.5).sum())} at 0.5)")
Output
Lowest threshold with precision >= 0.85: 0.72
  precision: 0.8519
  recall   : 0.3791
  times the model said yes: 81 (it was 206 at 0.5)

There is the answer. We did not retrain the model and we did not change a single line of it. We moved the threshold.

It has a price: recall dropped. What you tell the manager is: "Precision is up to 0.85, but now we will find fewer of the flats that go fast. That is the trade."

Finding the threshold with the best F1 automatically

python4 lines
prec, rec, thr = precision_recall_curve(y_te, proba)
f1 = 2 * prec * rec / np.maximum(prec + rec, 1e-12)
best = int(np.nanargmax(f1[:-1]))
print(thr[best], f1[best])

But careful: a threshold you pick on the test set is optimistic. The right way is to pick it on validation or in CV.

python6 lines
from sklearn.metrics import precision_recall_curve
prec, rec, thr = precision_recall_curve(y_te, proba)
f1_curve = 2 * prec * rec / np.maximum(prec + rec, 1e-12)
best = int(np.nanargmax(f1_curve[:-1]))
print(f"Threshold with the best F1: {thr[best]:.3f}  ->  F1 = {f1_curve[best]:.4f}")
print(f"Default 0.5               :          F1 = {f1_score(y_te, pipe.predict(X_te)):.4f}")
Output
Threshold with the best F1: 0.511  ->  F1 = 0.7979
Default 0.5               :          F1 = 0.7938

Imbalanced data: ROC lies, PR does not

So far the classes were balanced. Now for the case that matters most, because it is the one you meet most often in practice: imbalanced data.

We create the imbalance ourselves: we cut the share of "went fast" down to about 2%.

python8 lines
np.random.seed(42)
fast = y_te[y_te == 1].index
slow = y_te[y_te == 0].index
sample = list(slow) + list(np.random.choice(fast, size=max(1, int(len(slow) * 0.02)), replace=False))

y_imb = y_te.loc[sample]
p_imb = pd.Series(proba, index=y_te.index).loc[sample].values
print(f"New set: {len(y_imb)} rows, share of 'went fast' {y_imb.mean():.2%}")
Output
New set: 181 rows, share of 'went fast' 1.66%

Now we measure two metrics side by side.

python11 lines
from sklearn.metrics import roc_auc_score, average_precision_score

auc = roc_auc_score(y_imb, p_imb)
ap = average_precision_score(y_imb, p_imb)

print(f"ROC-AUC          : {auc:.4f}")
print(f"Average Precision: {ap:.4f}")
print()
print("Baseline for a random model:")
print(f"  ROC-AUC = 0.5000      (ALWAYS, whatever the imbalance)")
print(f"  AP      = {y_imb.mean():.4f}      (= share of the positive class)")
Output
ROC-AUC          : 0.9551
Average Precision: 0.2342

Baseline for a random model:
  ROC-AUC = 0.5000      (ALWAYS, whatever the imbalance)
  AP      = 0.0166      (= share of the positive class)

ROC-AUC looks great. AP is very low.

Why the gap? Look at the formulas.

python14 lines
b_imb = (p_imb >= 0.5).astype(int)
from sklearn.metrics import confusion_matrix
tn, fp, fn, tp = confusion_matrix(y_imb, b_imb).ravel()
print(f"TN={tn}  FP={fp}  FN={fn}  TP={tp}")
print()
print("The second axis of ROC is FPR:")
print(f"  FPR = FP / (FP + TN) = {fp} / ({fp} + {tn}) = {fp/(fp+tn):.4f}")
print()
print("TN is huge (many negatives) -> huge denominator -> FPR is always small.")
print("So the ROC curve hugs the top-left corner and LOOKS BEAUTIFUL.")
print()
print("The PR curve does NOT use TN at all:")
print(f"  precision = TP / (TP + FP) = {tp} / ({tp} + {fp}) = {tp/(tp+fp) if tp+fp else 0:.4f}")
print(f"  recall    = TP / (TP + FN) = {tp} / ({tp} + {fn}) = {tp/(tp+fn) if tp+fn else 0:.4f}")
Output
TN=126  FP=52  FN=0  TP=3

The second axis of ROC is FPR:
  FPR = FP / (FP + TN) = 52 / (52 + 126) = 0.2921

TN is huge (many negatives) -> huge denominator -> FPR is always small.
So the ROC curve hugs the top-left corner and LOOKS BEAUTIFUL.

The PR curve does NOT use TN at all:
  precision = TP / (TP + FP) = 3 / (3 + 52) = 0.0545
  recall    = TP / (TP + FN) = 3 / (3 + 0) = 1.0000

That is the reason. TN is ROC's hidden helper. When negatives are plentiful, it makes ROC look artificially good.

The PR curve only uses TP, FP and FN, so it talks only about the class you care about.

python19 lines
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve

fpr, tpr, _ = roc_curve(y_imb, p_imb)
p2, r2, _ = precision_recall_curve(y_imb, p_imb)

figure, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].plot(fpr, tpr, "b-", lw=2, label=f"AUC = {auc:.3f}")
axes[0].plot([0, 1], [0, 1], "k--", alpha=.5, label="Random (0.5)")
axes[0].set_xlabel("False Positive Rate"); axes[0].set_ylabel("True Positive Rate")
axes[0].set_title("ROC — LOOKS beautiful", color="darkorange", fontweight="bold")
axes[0].legend(); axes[0].grid(alpha=.3)

axes[1].plot(r2, p2, "r-", lw=2, label=f"AP = {ap:.3f}")
axes[1].axhline(y_imb.mean(), color="k", ls="--", alpha=.5, label=f"Random ({y_imb.mean():.3f})")
axes[1].set_xlabel("Recall"); axes[1].set_ylabel("Precision")
axes[1].set_title("PR — shows the TRUTH", color="green", fontweight="bold")
axes[1].legend(); axes[1].grid(alpha=.3)
fig("roc_pr.png", caption="Same model, same data. The chart on the left is lying to you.")

Same model, same data. The chart on the left is lying to you.

Same model, same data. The chart on the left is lying to you.

When ROC, when PR

  • Classes roughly balanced -> ROC-AUC works well and is easy to read.
  • Classes imbalanced (positives < 10%) -> use PR-AUC (average precision).
  • If a paper reports "AUC 0.92" on imbalanced data, ask for the class ratio.

The rule: if you are looking for something rare (fraud, disease, failure), read PR.

class_weight="balanced" is not a magic fix

The most common advice for "fixing" imbalance is class_weight="balanced". Let's measure it.

python28 lines
idx = list(set(sample))
X_imb = X_te.loc[idx]
y_imb2 = y_te.loc[idx]
X_itr, X_ite, y_itr, y_ite = train_test_split(X_imb, y_imb2, test_size=0.4,
                                              random_state=42, stratify=y_imb2)

def small_pipe(**kw):
    return Pipeline([
        ("prep", ColumnTransformer([
            ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                              ("scale", StandardScaler())]), num),
            ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
        ])),
        ("model", LogisticRegression(max_iter=3000, **kw)),
    ])

plain = small_pipe().fit(X_itr, y_itr)
balanced = small_pipe(class_weight="balanced").fit(X_itr, y_itr)

rows = []
for name, m in [("Plain", plain), ("class_weight=balanced", balanced)]:
    b = m.predict(X_ite)
    rows.append({"Model": name,
                 "Accuracy": round(accuracy_score(y_ite, b), 4),
                 "Precision": round(precision_score(y_ite, b, zero_division=0), 4),
                 "Recall": round(recall_score(y_ite, b, zero_division=0), 4),
                 "F1": round(f1_score(y_ite, b, zero_division=0), 4)})
show(pd.DataFrame(rows))
ModelAccuracyPrecisionRecallF1
Plain0.986000
class_weight=balanced0.973000

What did class_weight="balanced" do? It told the model "mistakes on the rare class cost more". So the model says "yes" more often: recall goes up, precision goes down.

That is not a fix. It is tilting the scales to one side, which is exactly what moving the threshold does too.

There is no such thing as "solving" imbalance

class_weight, SMOTE, oversampling, undersampling: none of them add new knowledge to the data. They only answer the question which mistake do you prefer.

If you have 20 positive examples, no technique turns them into 2000. The fix is to collect more data or to reframe the problem.

macro or weighted: which one do you read

python3 lines
from sklearn.metrics import classification_report
print(classification_report(y_ite, plain.predict(X_ite),
                            target_names=["slow", "fast"], digits=4, zero_division=0))
Output
              precision    recall  f1-score   support

        slow     0.9863    1.0000    0.9931        72
        fast     0.0000    0.0000    0.0000         1

    accuracy                         0.9863        73
   macro avg     0.4932    0.5000    0.4966        73
weighted avg     0.9728    0.9863    0.9795        73

Notice: weighted avg reflects the large class and hides the small one. macro avg counts both classes equally, which is why it is lower and more honest.

The rule: on imbalanced data, read macro.

What we learned today

python9 lines
show(pd.DataFrame([
    {"Concept": "predict() has a 0.5 inside", "In one sentence": "It is a default value, not a mathematical truth"},
    {"Concept": "Moving the threshold", "In one sentence": "Choose precision vs recall without retraining"},
    {"Concept": "Raise the threshold", "In one sentence": "Precision goes up, recall goes down, every time"},
    {"Concept": "ROC-AUC", "In one sentence": "Fine on balanced classes; LIES on imbalanced ones (because of TN)"},
    {"Concept": "PR-AUC (AP)", "In one sentence": "Does not use TN, so it tells the truth on imbalanced data"},
    {"Concept": "class_weight=balanced", "In one sentence": "Not a fix, a tilt of the scales"},
    {"Concept": "macro vs weighted", "In one sentence": "On imbalanced data, read macro"},
]))
ConceptIn one sentence
predict() has a 0.5 insideIt is a default value, not a mathematical truth
Moving the thresholdChoose precision vs recall without retraining
Raise the thresholdPrecision goes up, recall goes down, every time
ROC-AUCFine on balanced classes; LIES on imbalanced ones (because of TN)
PR-AUC (AP)Does not use TN, so it tells the truth on imbalanced data
class_weight=balancedNot a fix, a tilt of the scales
macro vs weightedOn imbalanced data, read macro

Next chapter: every number so far came from one split. Change the split and the numbers change too. That is the problem we solve next.

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