Machine Learning · Module 19 of 26 · 10 concepts · about 19 min

Imbalanced Learning & Thresholding

When the positive class is 1% of the rows. Why accuracy stops meaning anything, the three ways to respond, and how to choose a decision threshold on purpose instead of inheriting 0.5.

At work: Predicting rare events like fraud, defaults or equipment failures, where the thing you care about is 1 in 1,000.

The accuracy paradox

What it is

On a dataset that is 99% negative, a model that predicts 'negative' for every row is 99% accurate. It has learned nothing, it catches nothing, and it beats most honest first attempts on the metric you were watching.

Why it matters

It is the reason every metric decision in this module exists. Accuracy averages over the classes weighted by how common they are, so on imbalanced data it is almost entirely a measurement of the majority class — and the majority class is the one nobody asked about.

In the real world: A loan-default model that says “no one defaults” is 97% accurate and completely useless.

Visual example

Accuracy paradox

The all-negative classifier and a real model, on the same 10,000 rows. Accuracy cannot tell them apart; nothing else is confused.

accuracy_score(y, pred)

0.990 vs 0.991 vs 0.975 — it prefers the useless model to the useful one

recall_score(y, pred)

0.00 vs 0.22 vs 0.79 — the three models are completely different

9,900 negative / 100 positive
modelaccuracyrecallF1
predict 0 always0.9900
logistic regression @ 0.50.9910.220.32
same model @ threshold 0.080.9750.790.44

Row 3 catches 79% of the positives and scores *lower* on accuracy than row 1

Where 0.99 comes from

9,900 negatives all predicted correctly, 100 positives all missed

accuracy = 9900 / 10000 = 0.990

balanced accuracy = (1.000 + 0.000) / 2 = 0.500

The 1% of rows anyone cares about contributes 1% of the metric

Why this matters

The third row is the model you would actually ship, and it is the worst of the three on accuracy. If accuracy is the number on the dashboard, shipping it will look like a regression.

Common misconception

Wrong idea: 99% accuracy means the model is nearly always right.

Correct idea: it means the model is nearly always right about the common class. Report per-class recall, balanced accuracy or average precision, and accuracy stops being able to mislead you.

import numpy as np
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, balanced_accuracy_score, f1_score
from sklearn.model_selection import train_test_split

y = np.concatenate([np.zeros(9900), np.ones(100)])
X = np.random.default_rng(0).standard_normal((10000, 5))    # pure noise
X_tr, X_te, y_tr, y_te = train_test_split(X, y, stratify=y, test_size=0.3, random_state=0)

dummy = DummyClassifier(strategy="most_frequent").fit(X_tr, y_tr)
pred = dummy.predict(X_te)
accuracy_score(y_te, pred)            # 0.99
balanced_accuracy_score(y_te, pred)   # 0.50 — the honest version
f1_score(y_te, pred, zero_division=0) # 0.00

Using accuracy as the scoring argument in a grid search on imbalanced data. Every hyperparameter that makes the model *more* willing to predict the positive class will score worse, so the search converges on the most conservative model in the space — often the one that never predicts positive at all.

Resampling: oversampling the minority

What it is

Duplicate or synthesise minority rows until the classes are balanced. RandomOverSampler copies existing rows; SMOTE and its variants create new ones. Nothing is discarded, so the majority class keeps all its information.

Why it matters

It is the remedy that requires no support from the estimator. Any model at all can be trained on a rebalanced table, which matters when the library you are using has no class-weight parameter.

In the real world: Showing the model the rare fraud cases more often so it learns to recognise them.

Visual example

imblearn

Oversampling changes the training class counts only. The validation and test sets must keep the real prevalence.

y_train — the real distribution
classrowsshare
negative990099.0%
positive1001.0%
RandomOverSampler(sampling_strategy=0.5)
y_over — 4,850 duplicated rows added
classrowsshare
negative990066.7%
positive495033.3%

What actually got added

100 distinct positive rows, resampled with replacement to 4,950

Each original row now appears about 49.5 times

Distinct information in the positive class: still 100 rows

A depth-20 tree can now fit those 100 rows perfectly and generalise to none of them

Why this matters

The counts change and the information does not. That gap is why oversampling helps a linear model's loss balance and can actively hurt a deep tree.

Common misconception

Wrong idea: oversampling gives the model more information about the minority class.

Correct idea: it gives the loss function more weight on the same 100 rows. Duplicating a row adds no evidence; it only changes how much a mistake on it costs.

from collections import Counter
from imblearn.over_sampling import RandomOverSampler, SMOTE
from imblearn.under_sampling import RandomUnderSampler

print(Counter(y_train))                       # {0: 9900, 1: 100}

over = RandomOverSampler(sampling_strategy=0.5, random_state=0)   # minority to 50% of majority
X_over, y_over = over.fit_resample(X_train, y_train)
print(Counter(y_over))                        # {0: 9900, 1: 4950}

under = RandomUnderSampler(sampling_strategy=0.5, random_state=0)
X_under, y_under = under.fit_resample(X_train, y_train)
print(Counter(y_under))                       # {0: 200, 1: 100}

Random oversampling duplicates rows exactly, so a flexible model can memorise a single minority row it has now seen fifty times — the minority training error goes to zero while nothing generalises. Undersampling has the opposite problem: at 1% prevalence, balancing by undersampling throws away 98% of the majority rows and the information in them.

Practise it: A Bootstrap Confidence Interval for a Metric

SMOTE: synthesising minority points

What it is

For a minority row, pick one of its k nearest minority neighbours and place a new row at a random position on the segment between them. The result is new coordinates rather than duplicates, so the minority class becomes a filled-in region instead of a set of repeated points.

Why it matters

It addresses oversampling's memorisation problem directly: the model sees a continuum of plausible minority examples, which tends to push the decision boundary outward around the minority region rather than tightly around the few real points.

In the real world: Creating realistic extra examples of rare equipment failures to train a maintenance model.

Visual example

SMOTE

Three real minority points and four synthetic ones placed on the segments between nearest neighbours.

majority classminority class (real)synthetic (SMOTE)
01234500.511.522.533.5feature 1 (scaled)feature 2 (scaled)

One synthetic point, worked

Pick a real minority row: (2.0, 1.6)

Pick one of its k nearest MINORITY neighbours: (2.6, 1.9)

Draw lambda ~ U(0, 1); say lambda = 0.5

new = (2.0, 1.6) + 0.5 * ((2.6, 1.9) - (2.0, 1.6)) = (2.3, 1.75)

Repeat until the minority count reaches sampling_strategy

Why this matters

Every orange point lies on a line between two red ones, which is both the method and its assumption. Where the straight line between two minority rows passes through majority territory, SMOTE puts a minority label there.

Common misconception

Wrong idea: SMOTE generates realistic new examples of the minority class.

Correct idea: it generates linear interpolations of the examples you already have. It cannot invent a feature combination outside the convex hull of the real minority rows, and inside that hull it will happily invent impossible ones.

from imblearn.over_sampling import SMOTE, SMOTENC, BorderlineSMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler

# k_neighbors must be < the number of minority rows in every fold.
pipe = ImbPipeline([
    ("scale", StandardScaler()),
    ("smote", SMOTE(k_neighbors=5, random_state=0)),
    ("clf", LogisticRegression(max_iter=2000)),
])

# Mixed data: SMOTENC handles categorical columns by taking the neighbour majority.
SMOTENC(categorical_features=[3, 7], random_state=0)

# Synthesise only near the boundary, where the model is actually confused.
BorderlineSMOTE(random_state=0)

SMOTE interpolates in feature space, which assumes the space is continuous and the minority region convex. A midpoint between two categorical one-hot rows is nonsense; a midpoint between two minority rows on opposite sides of a majority region lands *inside* the majority region and manufactures a mislabelled row. And a minority outlier gets interpolated with its nearest neighbours too, spreading noise rather than signal — which is what BorderlineSMOTE and cleaning variants like SMOTEENN exist to limit.

Class weights instead of resampling

What it is

Leave the data alone and multiply each class's contribution to the loss. class_weight="balanced" sets w_c = n / (k * n_c), so a class with 1% of the rows gets roughly 50 times the weight of one with 99%. Most scikit-learn estimators accept it, and boosting libraries expose scale_pos_weight.

Why it matters

It achieves what oversampling achieves without touching the rows: no duplicated data, no synthesised coordinates, no interaction with cross-validation, and no extra memory. It is the first thing to try, and often the last.

In the real world: Telling the model a missed fraud costs 100 times more than a false alarm, without changing the data.

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.utils.class_weight import compute_class_weight
import numpy as np

classes = np.unique(y_train)
compute_class_weight("balanced", classes=classes, y=y_train)   # array([0.505, 50.0])

LogisticRegression(class_weight="balanced", max_iter=2000).fit(X_train, y_train)
LogisticRegression(class_weight={0: 1.0, 1: 20.0}).fit(X_train, y_train)   # a chosen ratio

# Forests: weight per class, or per bootstrap sample.
RandomForestClassifier(class_weight="balanced_subsample", random_state=0)

# Boosting: one scalar, the negative/positive ratio.
# XGBClassifier(scale_pos_weight=9900 / 100)

It changes the predicted probabilities. A weighted model's output is no longer an estimate of P(y=1 | x) on the real distribution — it is calibrated to the reweighted one — so 0.5 stops meaning 'more likely than not' and any expected-cost calculation built on those numbers is wrong. Same for resampling. See the calibration concept below.

Practise it: A Bootstrap Confidence Interval for a Metric

Choosing the decision threshold

What it is

A classifier outputs a score; predict applies a cut at 0.5 that nobody chose. Sweep the threshold, compute precision and recall at each value, and pick the point that matches the operating requirement — 'recall at least 0.90', or 'no more than 3 false alarms per catch'.

Why it matters

It is the cheapest improvement available on an imbalanced problem, and it needs no retraining. Moving the cut from 0.5 to 0.08 on the same fitted model can take recall from 0.22 to 0.79 — a larger change than most model swaps will buy you.

In the real world: Setting the fraud score at which a card is blocked to balance lost fraud against annoyed customers.

Visual example

precision_recall_curve

Precision and recall against the threshold. They move in opposite directions by construction, and 0.5 is not a special point on either curve.

precisionrecallF1
Recall firstPrecision first00.20.40.60.8100.20.40.60.81Decision thresholdScorethe defaultbest F1

Three defensible cuts on one fitted model

t = 0.50 (default): P 0.61, R 0.65, F1 0.63 — balanced, chosen by nobody

t = 0.22 (screening): P 0.28, R 0.91 — catch nine in ten, review 3.6 cases per catch

t = 0.80 (auto-action): P 0.88, R 0.23 — act without review, miss three in four

The model is identical in all three rows. Only the cut moved.

Why this matters

The two solid curves crossing is the trade you are actually making, drawn. The right cut depends on what a false positive costs relative to a false negative — which is the next concept, not a property of the model.

Common misconception

Wrong idea: 0.5 is the neutral, unbiased threshold.

Correct idea: 0.5 is neutral only if the two errors cost the same and the probabilities are calibrated. On imbalanced data with asymmetric costs, neither holds, and the default is an arbitrary choice you inherited.

import numpy as np
from sklearn.metrics import precision_recall_curve

proba = model.predict_proba(X_val)[:, 1]
precision, recall, thresholds = precision_recall_curve(y_val, proba)

# Requirement: recall >= 0.90. Take the most precise threshold that meets it.
ok = recall[:-1] >= 0.90
best = int(np.argmax(precision[:-1] * ok))
print(f"threshold {thresholds[best]:.3f} -> P {precision[best]:.3f}, R {recall[best]:.3f}")

# Or maximise F1 if there is genuinely no asymmetry between the two errors.
f1 = 2 * precision[:-1] * recall[:-1] / np.clip(precision[:-1] + recall[:-1], 1e-12, None)
chosen = float(thresholds[int(np.argmax(f1))])

pred = (model.predict_proba(X_test)[:, 1] >= chosen).astype(int)

Choosing the threshold on the test set. It is a fitted parameter like any other: select it on validation data or inside cross-validation, then apply it unchanged to the test set. Tuning it on the test set and reporting the resulting F1 is the same error as tuning a hyperparameter there.

PR-AUC over ROC-AUC on imbalanced data

What it is

ROC plots the true positive rate against the false positive rate; PR plots precision against recall. Both use the same scores, but the false positive rate divides by the huge negative count, so a large absolute number of false positives is a tiny FPR. Precision divides by the alerts you actually raised, so it does not hide them.

Why it matters

It is the single most common way an imbalanced model is oversold. ROC-AUC 0.94 and PR-AUC 0.38 describe the same classifier, and only one of them tells you that four in five alerts will be wrong.

In the real world: A more honest scorecard for rare-event models like fraud or default, where ROC-AUC can flatter.

Visual example

1% prevalence

The same classifier, same scores, drawn as an ROC curve and as a PR curve. One says 0.94; the other says 0.38.

ROC curve (AUC 0.94)PR curve (AP 0.38)ROC chance line
00.20.40.60.8100.20.40.60.81FPR (ROC) / Recall (PR)TPR (ROC) / Precision (PR)PR chance = prevalenceR 0.80 -> P 0.10

One operating point, both ways

10,000 rows: 100 positive, 9,900 negative

At recall 0.80: TP = 80, FN = 20, FP = 720

ROC reads it as FPR = 720 / 9900 = 0.073 — barely off the y-axis, looks excellent

PR reads it as precision = 80 / 800 = 0.10 — nine wrong alerts per catch

Same 720 false positives. Only the denominator changed.

Why this matters

The 720 false positives are present in both curves. Dividing them by 9,900 negatives buries them; dividing them by the 800 alerts you raised puts them in front of you.

Common misconception

Wrong idea: ROC-AUC 0.94 means the model is right 94% of the time, or nearly so.

Correct idea: it means a random positive outranks a random negative 94% of the time. That is compatible with 90% of your alerts being wrong, because there are 99 negatives for every positive to outrank.

from sklearn.metrics import (average_precision_score, roc_auc_score,
                             PrecisionRecallDisplay, RocCurveDisplay)

proba = model.predict_proba(X_test)[:, 1]
roc_auc_score(y_test, proba)             # 0.94 — flattering
average_precision_score(y_test, proba)   # 0.38 — the honest summary

# Baselines to compare against:
#   ROC-AUC of a random model  = 0.50, whatever the prevalence
#   PR-AUC  of a random model  = the prevalence itself, here 0.01
y_test.mean()

# Use average_precision as the CV scorer, not roc_auc, when positives are rare.
# GridSearchCV(..., scoring="average_precision")

Comparing PR-AUC across datasets or across time. Its baseline is the prevalence, so a PR-AUC of 0.30 at 1% positives is a far better model than 0.30 at 20% positives. ROC-AUC has a fixed 0.5 baseline and is the one that is comparable — which is exactly why it is insensitive to the thing you care about here.

Practise it: Pick a Threshold for an Imbalanced Classifier

Cost-sensitive learning

What it is

State what each error costs in the units of the actual problem — a missed fraud costs the transaction value, a false alarm costs a review — and choose the threshold that minimises expected cost. For calibrated probabilities the optimum is t* = C_FP / (C_FP + C_FN).

Why it matters

It replaces the argument about which metric to optimise with an arithmetic problem. F1 assumes the two errors are equally bad, which is almost never true; once the costs are written down the threshold is derived rather than debated.

In the real world: Building the real rupee costs of each kind of mistake into the model's training.

Visual example

Expected cost

A cost matrix, and the threshold it implies: 0.012, not 0.5, because a miss costs 80 times a false alarm.

False negative

(an unblocked fraud: 400)

cost per row, in currency
predicted negativepredicted positive
actually negative05
actually positive4000

C_FP / (C_FP + C_FN)

5 / 405 = 0.0123 — the cost-optimal cut

Act whenever P(fraud) > 1.2%, because 1.2% x 400 = 5

Total cost at three thresholds, on 10,000 val rows

t = 0.50: FP 8, FN 35 -> 8(5) + 35(400) = 14,040

t = 0.08: FP 196, FN 21 -> 196(5) + 21(400) = 9,380

t = 0.012: FP 890, FN 6 -> 890(5) + 6(400) = 6,850

The best-F1 threshold (0.5) costs more than twice the cost-optimal one

Why this matters

The threshold falls out of two numbers a business analyst can supply, with no metric argument at all. Note also that this is a *per-row* calculation when the cost varies — a 12-currency transaction and a 4,000-currency one do not deserve the same cut.

Common misconception

Wrong idea: cost-sensitive learning means adding class weights during training.

Correct idea: weights change what gets fitted; the costs also change where the cut goes, and the cut is where nearly all of the benefit is. You can be fully cost-sensitive with an unweighted model and a moved threshold.

import numpy as np

C_FP, C_FN = 5.0, 400.0          # a manual review vs an unblocked fraud
t_star = C_FP / (C_FP + C_FN)     # 0.0123

proba = model.predict_proba(X_val)[:, 1]
grid = np.linspace(0.001, 0.5, 500)
cost = [
    C_FP * ((proba >= t) & (y_val == 0)).sum() + C_FN * ((proba < t) & (y_val == 1)).sum()
    for t in grid
]
best = float(grid[int(np.argmin(cost))])
print(f"analytic {t_star:.4f}, empirical {best:.4f}, cost {min(cost):,.0f}")

# Per-row costs (the transaction amount) instead of one constant:
# weight each false negative by amount[i] rather than by C_FN.

The formula assumes the probabilities are calibrated, and resampling or class weighting breaks exactly that assumption. If you have reweighted the loss, calibrate before computing an expected cost — otherwise t* = 0.0123 is being applied to numbers that no longer mean probabilities.

Never resample the validation or test set

What it is

Resampling belongs to training. The validation and test sets must keep the real prevalence, because every prevalence-dependent metric — precision, PR-AUC, expected cost, precision@k — is meaningless when measured on a distribution the model will never meet.

Why it matters

It produces the most confidently wrong numbers in this whole module. Recall is unaffected by prevalence, so a balanced test set gives an honest-looking recall beside a precision that is inflated by a factor of the resampling ratio, and the report looks internally consistent.

In the real world: Testing on artificially balanced data makes a fraud model look far better than it will be live.

Visual example

Evaluation pitfall

One model, two test sets. Recall is identical; precision differs by a factor of three — and only the right-hand column will survive deployment.

recall_score(...)

0.79 either way — recall only looks at the positives

precision_score(...)

0.91 vs 0.28 — precision depends on how many negatives there were

the same fitted model, scored twice
test setrecallprecisionF1
balanced 50/50 (wrong)0.790.910.85
real 1% prevalence0.790.280.41

Reporting F1 = 0.85 for a model that will deliver 0.41

Where the inflation comes from

Balanced set: 1,000 pos / 1,000 neg. At FPR 0.078: TP 790, FP 78 -> P = 0.91

Real set: 100 pos / 9,900 neg. Same FPR: TP 79, FP 772 -> P = 0.28

The model's behaviour is identical. Only the number of negatives changed.

Recall = TP / (TP + FN) never touches the negative count, so it does not move

Why this matters

Recall matching across the two rows is what makes the wrong number persuasive: it looks like the metrics agree. The tell is that precision changed while the model did not.

Common misconception

Wrong idea: a balanced test set gives a fairer view of the model.

Correct idea: it gives a view of a world that does not exist. The model will meet 99 negatives per positive, and precision is the metric that counts them.

from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split

# Split FIRST, at the real prevalence, and stratify so each side keeps it.
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=0)
print(y_tr.mean(), y_te.mean())         # both about 0.01

# Resample the training half only.
X_bal, y_bal = SMOTE(random_state=0).fit_resample(X_tr, y_tr)
model.fit(X_bal, y_bal)

# Evaluate on y_te, untouched.
precision_score(y_te, model.predict(X_te))
recall_score(y_te, model.predict(X_te))

Resampling before train_test_split. Duplicated or interpolated copies of the same source row then land on both sides of the split, so the model is evaluated on rows it effectively trained on — a leak that is invisible in the code and can add 0.2 to reported precision on its own.

Resampling inside cross-validation

What it is

Resampling is a fitted step, so it must happen inside each fold, on that fold's training portion only. imblearn.pipeline.Pipeline is a drop-in replacement for scikit-learn's that applies its samplers on fit and skips them on transform/predict — which is exactly the asymmetry this needs.

Why it matters

Resampling before the split, or before cross_val_score, puts copies of the same source row in both the training and validation halves of every fold. The reported score rises, sometimes a great deal, and the model has learned nothing extra.

In the real world: Keeping synthetic examples out of the test slices so reported recall is real.

from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler

# WRONG: the synthesised rows are in every fold's validation half too.
# X_bal, y_bal = SMOTE(random_state=0).fit_resample(X, y)
# cross_val_score(model, X_bal, y_bal, cv=5, scoring="average_precision")

# RIGHT: SMOTE is refitted on each fold's training rows, and never applied
# to the fold's validation rows.
pipe = ImbPipeline([
    ("scale", StandardScaler()),
    ("smote", SMOTE(k_neighbors=5, random_state=0)),
    ("clf", RandomForestClassifier(n_estimators=300, random_state=0)),
])
cv = StratifiedKFold(5, shuffle=True, random_state=0)
cross_val_score(pipe, X, y, cv=cv, scoring="average_precision").mean()

Using sklearn.pipeline.Pipeline for a sampler. It has no concept of a step that changes the number of rows, so it will either raise or silently apply the sampler at predict time — resampling your validation data, which is the previous concept's error arriving through a different door. Import Pipeline from imblearn.pipeline. And always use StratifiedKFold here: with 100 positives across 5 folds, an unstratified split can hand a fold 8 positives and break SMOTE's k_neighbors outright.

Practise it: A Bootstrap Confidence Interval for a Metric, A Pipeline That Does Not Leak, A Split That Does Not Leak Across Groups

Calibration after rebalancing

What it is

Resampling and class weighting both change the class prior the model is fitted against, so its output is a probability under the *rebalanced* distribution, not the real one. Calibration maps those scores back onto honest probabilities, with CalibratedClassifierCV (isotonic or sigmoid) fitted on held-out data.

Why it matters

Everything downstream that treats a score as a probability — expected cost, a risk band shown to a user, a decision rule combining a probability with a monetary amount — is wrong until this is fixed. Ranking is unaffected, which is why the problem is easy to miss: ROC-AUC and PR-AUC look fine.

In the real world: After oversampling, a model says “30% fraud” when the true rate is 1%; recalibrating fixes the probabilities that set limits and prices.

Visual example

CalibratedClassifierCV

A reliability curve. After oversampling the model claims 0.50 for rows that are positive 17% of the time; isotonic calibration puts them back on the diagonal.

perfectly calibratedafter SMOTE, uncalibratedisotonic, on held-out rows
00.20.40.60.8100.20.40.60.81Predicted probabilityObserved positive rateclaims 50%, is 17%

Why the red curve sits below the diagonal

Training prevalence after SMOTE: 0.50. Real prevalence: 0.01.

The model is fitted to expect positives 50 times more often than they occur

So every score is pushed up: mean predicted 0.33 against an actual rate of 0.01

Expected cost at t* = 0.0123 on these scores flags essentially every row

Ranking is unchanged, so ROC-AUC and PR-AUC are identical for both curves

Why this matters

Both coloured curves describe the same ranking and therefore the same AUC. Only the red one will wreck an expected-cost calculation, and no ranking metric will warn you.

Common misconception

Wrong idea: predict_proba returns a probability, so it can be compared against a cost ratio directly.

Correct idea: it returns a score in [0, 1]. It is a probability only if the model was fitted on the real class balance and is well calibrated; rebalancing guarantees it is not.

import numpy as np
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.metrics import brier_score_loss

raw = model.predict_proba(X_val)[:, 1]
print(f"mean predicted {raw.mean():.3f} vs actual rate {y_val.mean():.3f}")   # 0.33 vs 0.01

cal = CalibratedClassifierCV(model, method="isotonic", cv=5).fit(X_tr, y_tr)
p = cal.predict_proba(X_val)[:, 1]
print(f"mean predicted {p.mean():.3f}")                     # about 0.01

brier_score_loss(y_val, raw), brier_score_loss(y_val, p)    # the second is much lower
frac_pos, mean_pred = calibration_curve(y_val, p, n_bins=10)

Calibrating on the same resampled data the model was trained on. The calibrator then learns to map scores onto the balanced prevalence and reproduces the original problem exactly. Calibrate on held-out rows at the real prevalence — and note that CalibratedClassifierCV wrapping an imblearn pipeline does the right thing only because its internal CV splits the untouched data.

Practise it: How Wrong Are Your Probabilities?