Skip to main content
PProfileMatch

Class Imbalance, Metric Choice and Data Leakage: Why Results Look Too Good

Machine LearningLesson 4 of 46 min read

What you'll learn

  • Show how accuracy misleads on an imbalanced problem and what thresholds and class weights change
  • Choose between precision, recall and the decision threshold from business costs
  • Recognise target leakage and preprocessing leakage in a feature list or pipeline
  • Demonstrate leakage with feature selection outside cross-validation and fix it

On this page

  1. Imbalance: when the thing you care about is rare
  2. Leakage: when the model peeks at the answer
  3. Common mistakes and the symptoms they cause
  4. How this shows up in real projects and interviews

A logistics company ships thousands of parcels a day and about one in twenty-five arrives damaged. A data scientist trains a classifier on two sensor readings, reports 96% accuracy and is congratulated. The model flags six parcels out of two thousand. This lesson takes apart that story and a second, quieter one, where cross-validation reports 82% accuracy on data that is pure random noise. Both are ways of producing results that look excellent and mean nothing.

Imbalance: when the thing you care about is rare

The experiment below creates 2,000 training and 2,000 test parcels, about 4% damaged, with damaged parcels scoring higher on both sensors by a modest shift. Because the two groups overlap, no model can be perfect; the question is what each choice trades. We train a logistic regression three ways and compare it with the always-fine baseline.

parcel_imbalance.py
import math

class Lcg:
    def __init__(self, seed):
        self.state = seed
    def next(self):
        self.state = (1103515245 * self.state + 12345) % (2 ** 31)
        return self.state / 2 ** 31
    def normal(self):                       # sum of 12 uniforms ~ bell curve
        return sum(self.next() for _ in range(12)) - 6

# Parcel damage in transit: about 4% of parcels arrive damaged (1)
rng = Lcg(5)
def make(n):
    rows = []
    for _ in range(n):
        damaged = 1 if rng.next() < 0.04 else 0
        shift = 1.2 * damaged                          # damaged parcels score higher on both sensors
        rows.append(([rng.normal() + shift, rng.normal() + shift], damaged))
    return rows

train, test = make(2000), make(2000)
print("damaged in train:", sum(y for _, y in train), "of", len(train))

def sigmoid(z):
    return 1 / (1 + math.exp(-z))

def fit(rows, pos_weight=1.0, steps=300, lr=0.3):
    w, b = [0.0, 0.0], 0.0
    for _ in range(steps):
        gw, gb, total = [0.0, 0.0], 0.0, 0.0
        for x, y in rows:
            wt = pos_weight if y == 1 else 1.0
            err = wt * (sigmoid(w[0] * x[0] + w[1] * x[1] + b) - y)
            gw[0] += err * x[0]
            gw[1] += err * x[1]
            gb += err
            total += wt
        w = [w[0] - lr * gw[0] / total, w[1] - lr * gw[1] / total]
        b -= lr * gb / total
    return w, b

def evaluate(model, rows, threshold, label):
    w, b = model
    tp = fp = fn = tn = 0
    for x, y in rows:
        pred = sigmoid(w[0] * x[0] + w[1] * x[1] + b) >= threshold
        if pred and y: tp += 1
        elif pred and not y: fp += 1
        elif y: fn += 1
        else: tn += 1
    acc = (tp + tn) / len(rows)
    prec = tp / (tp + fp) if tp + fp else 0.0
    rec = tp / (tp + fn) if tp + fn else 0.0
    print(f"{label:<28} acc={acc:.3f} precision={prec:.2f} recall={rec:.2f} flagged={tp + fp}")

plain = fit(train)
weighted = fit(train, pos_weight=12.0)
print("always-ok baseline accuracy:", round(1 - sum(y for _, y in test) / len(test), 3))
evaluate(plain, test, 0.5, "plain, threshold 0.5")
evaluate(plain, test, 0.1, "plain, threshold 0.1")
evaluate(weighted, test, 0.5, "weighted x12, threshold 0.5")

Output

damaged in train: 74 of 2000
always-ok baseline accuracy: 0.961
plain, threshold 0.5         acc=0.964 precision=1.00 recall=0.08 flagged=6
plain, threshold 0.1         acc=0.900 precision=0.23 recall=0.67 flagged=227
weighted x12, threshold 0.5  acc=0.892 precision=0.22 recall=0.68 flagged=244

The first model has the best accuracy (0.964), barely above the 0.961 you get from never flagging anything, and it finds 8% of damaged parcels. Its precision is perfect only because it flags six cases it is very sure about. Lowering the threshold to 0.1, or giving damaged parcels twelve times the weight during training, gives nearly identical behaviour: about two thirds of damaged parcels caught, at the price of flagging between 227 and 244 parcels, of which roughly one in five is truly damaged. Accuracy dropped to 0.90 and 0.89, yet these are the more useful models if an inspection is cheap and a missed damage is expensive.

Two practical points follow. Class weighting and threshold shifting are two routes to the same trade-off, and often threshold tuning is simpler because you do not retrain. And the right operating point comes from costs: if inspecting a parcel costs one unit and a missed damage costs fifteen, flagging five suspects to catch one real problem already pays off. Choose the metric and threshold from those numbers, decided before looking at the test set. For definitions of the metrics, see Machine Learning Metrics and Your First Model.

Leakage: when the model peeks at the answer

Data leakage means information that would not exist at prediction time sneaks into training. It comes in two main flavours. *Target leakage* is a feature that is a consequence of the label: a "claim_filed" column used to predict whether a parcel was damaged, or "days_since_cancellation" in a churn model. It produces suspiciously perfect scores. *Preprocessing leakage* is subtler: a step that learns from the data (scaling, imputing, selecting features) is fitted on everything before the split, so test rows have already influenced training.

To prove how powerful the second kind is, the next script builds 60 customers with 500 random columns and coin-flip labels. There is nothing to learn; honest accuracy has to hover around 50%. We run five-fold cross-validation twice: once selecting the ten "best" columns using all labels first, and once selecting them inside each fold using only the training part.

leakage_demo.py
# Pure noise: 60 customers, 500 random columns, labels that are coin flips.
# Honest accuracy must hover around 50%.
class Lcg:
    def __init__(self, seed):
        self.state = seed
    def next(self):
        self.state = (1103515245 * self.state + 12345) % (2 ** 31)
        return self.state / 2 ** 31

rng = Lcg(99)
N, P, KEEP = 60, 500, 10
X = [[rng.next() - 0.5 for _ in range(P)] for _ in range(N)]
y = [1 if rng.next() < 0.5 else 0 for _ in range(N)]

def top_columns(rows_idx, k):
    """Pick the k columns whose class means differ most (computed on the given rows)."""
    scores = []
    for j in range(P):
        pos = [X[i][j] for i in rows_idx if y[i] == 1]
        neg = [X[i][j] for i in rows_idx if y[i] == 0]
        scores.append((abs(sum(pos) / len(pos) - sum(neg) / len(neg)), j))
    return [j for _, j in sorted(scores, reverse=True)[:k]]

def centroid_predict(train_idx, cols, row):
    cents = {}
    for label in (0, 1):
        members = [i for i in train_idx if y[i] == label]
        cents[label] = [sum(X[i][j] for i in members) / len(members) for j in cols]
    d = {lab: sum((X[row][j] - c) ** 2 for j, c in zip(cols, cents[lab])) for lab in (0, 1)}
    return 0 if d[0] <= d[1] else 1

def cross_val(select_inside):
    K = 5
    folds = [list(range(N))[i::K] for i in range(K)]
    hits = 0
    leaky_cols = top_columns(list(range(N)), KEEP)              # looked at ALL labels
    for i in range(K):
        val = folds[i]
        train = [r for j, f in enumerate(folds) if j != i for r in f]
        cols = top_columns(train, KEEP) if select_inside else leaky_cols
        hits += sum(centroid_predict(train, cols, r) == y[r] for r in val)
    return hits / N

print("labels are coin flips; honest accuracy should be near 0.50")
print("feature selection BEFORE cross-validation:", round(cross_val(False), 3))
print("feature selection INSIDE each fold       :", round(cross_val(True), 3))

Output

labels are coin flips; honest accuracy should be near 0.50
feature selection BEFORE cross-validation: 0.817
feature selection INSIDE each fold       : 0.55

A model with no real signal scored 82% because the selection step had already looked at every label, including the ones it was later tested on. Among 500 random columns, some will correlate with any set of 60 labels purely by chance, and choosing those columns "learns" the validation answers. Done properly, inside each fold, the score falls back to 55%, which is within chance variation of 50% for 60 rows. If you ever see a spectacular score on a small dataset with many columns, suspect this first.

Common mistakes and the symptoms they cause

  • Celebrating accuracy on a rare class. 96.4% against a 96.1% do-nothing baseline: the model flagged six items out of two thousand.
  • Rebalancing the test set. Oversampling or weighting is for training only; if you rebalance the test data, precision and accuracy no longer reflect production, where the rare class is rare.
  • Features computed after the outcome. Perfect validation scores, then collapse in production where the feature is empty or unavailable.
  • Feature selection or scaling before the split. Impressive offline numbers that vanish on genuinely new data, as the 0.817 against 0.55 shows.
  • Same customer on both sides of the split. Rows from one entity are near-duplicates, so the model recognises rather than predicts; split by entity instead of by row.

How this shows up in real projects and interviews

Leakage and imbalance are the two most common reasons a model that dazzled in the notebook disappoints in production, and experienced reviewers look for them first. In interviews, "your model has 98% accuracy on fraud detection, what do you check?" is a standard prompt. Cover the baseline, the confusion matrix, precision and recall at a chosen threshold, the cost of each error, whether any feature uses information from after the event, and whether preprocessing was fitted inside the training folds. If you can also describe how you would monitor the model after launch, because real data drifts, you will stand out. For wider interview preparation, see how to crack FAANG interviews.

Try it yourself

Extend the parcel experiment: compute precision and recall at thresholds 0.05, 0.1, 0.2, 0.3 and 0.5 for the plain model and print them as a table. Then suppose an inspection costs 1 unit and a missed damaged parcel costs 15 units. Compute the total cost on the test set at each threshold and say which one you would deploy.

Show a hint

Total cost = flagged count x 1 + missed damaged (false negatives) x 15. You can reuse the confusion counts from evaluate by returning them instead of printing.

Key takeaways

  • On rare-class problems, compare accuracy with the do-nothing baseline; a 96.4% model flagging six of 2,000 items is nearly useless.
  • Threshold shifting and class weights trade precision for recall; choose the operating point from the cost of each error.
  • Target leakage is a feature that exists only because of the outcome; preprocessing leakage is a learned step fitted before the split.
  • Fit scaling, imputation and feature selection inside each training fold, and rebalance only training data.

Test yourself

A trick from this lesson's ride: Few flags, all hits: great precision, tiny recall. Count what slipped through. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How do you handle imbalanced data in classification?

Start by checking a do-nothing baseline and a confusion matrix instead of accuracy. Then try shifting the decision threshold, weighting the rare class during training, or resampling the training data only. Choose the final threshold from the cost of false positives and false negatives, and evaluate on untouched, naturally imbalanced test data.

What is data leakage in machine learning?

Data leakage is when information that will not be available at prediction time influences training or evaluation. Examples are a feature that is recorded after the outcome, or scaling and feature selection fitted on the whole dataset before splitting. It inflates offline scores and usually collapses in production.

Why is my model's accuracy too good to be true?

Suspect leakage or a trivial baseline. Check whether any feature reveals the label, whether duplicates or the same entity appear in both train and test, and whether preprocessing was fitted on all data. Also compare against an always-predict-the-majority baseline, since high accuracy on a rare class is easy to achieve.