EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Undersampling

Undersampling is balancing a training set by throwing majority rows away until the two classes are the same size, or any size ratio you name.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

What imbalance does to a model ended with a model that scored 0.99 and found none of the 15 minority rows in the test set. The first way to change that is to change what the model is trained on.

Syntax: every sampler in imbalanced-learn has the same two-line shape, and every one of its constructor arguments is keyword-only.

python
from imblearn.under_sampling import RandomUnderSampler

under = RandomUnderSampler(sampling_strategy="auto", random_state=42)
X_res, y_res = under.fit_resample(X, y)   # returns new X and y, nothing in place

Older material passes the ratio as the first positional argument. That has not worked for several releases, and the error says exactly why.

ExampleThe call every pre-2021 notebook makes
from imblearn.under_sampling import RandomUnderSampler

RandomUnderSampler(0.75)
Watch out. RandomUnderSampler(0.75) raises TypeError: __init__() takes 1 positional argument but 2 were given, and ratio=0.75 raises TypeError: unexpected keyword argument 'ratio'. The argument is sampling_strategy= and it must be named. The old method name fit_sample is gone too; it is fit_resample.

Resampling the training half only

The split happens first, and only the training half is resampled. The test set keeps the class balance the model will meet in production, which is the only balance a score means anything against.

A flow showing where resampling belongs. The 5000 row dataset with 4950 and 50 rows goes into train_test_split with stratify set to y. That produces a training set of 3465 and 35 rows and a test set of 1485 and 15 rows. Only the training set is resampled, giving a balanced training set of 35 and 35 rows. Both paths meet at a fit, then score step, with the test path marked never touched. A dashed red panel underneath warns that resampling the whole dataset before the split copies minority rows, real or invented, into the test set, so the test score stops meaning anything.

Dropping majority rows at random

RandomUnderSampler keeps every minority row and draws majority rows at random until the counts match. random_state=42 fixes which majority rows survive, so two runs give the same training set.

python
from collections import Counter
from imblearn.under_sampling import RandomUnderSampler

under = RandomUnderSampler(random_state=42)
X_under, y_under = under.fit_resample(X_train, y_train)
Example3430 majority rows discarded from the training set
from collections import Counter
from imblearn.under_sampling import RandomUnderSampler

under = RandomUnderSampler(random_state=42)
X_under, y_under = under.fit_resample(X_train, y_train)

print("training set before:", Counter(y_train.tolist()))
print("training set after: ", Counter(y_under.tolist()))
print("test set, untouched:", Counter(y_test.tolist()))
  • 35 rows of each class are left. Every one of the 35 minority rows is kept; the 3465 majority rows are cut to 35.
  • 3430 training rows were thrown away, which is 98 per cent of what the majority class knew about itself.
  • The test set did not move. It still has 1485 and 15, the shares the model will meet.

Seeing what was discarded

ExampleThe same two axes, before and after
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)          # a readable sample of the left panel's cloud
before = X_train[y_train == 0]
shown = before[rng.choice(len(before), 1000, replace=False)]

fig, axes = plt.subplots(1, 2, figsize=(10, 4), sharex=True, sharey=True)
for ax, (grey, title) in zip(axes, [(shown, "training set: 3465 and 35 (1000 drawn)"),
                                    (X_under[y_under == 0], "after undersampling: 35 and 35")]):
    ax.scatter(grey[:, 0], grey[:, 1], s=8, color="#b9bfd0", label="class 0")
    ax.scatter(X_train[y_train == 1, 0], X_train[y_train == 1, 1], s=26,
               color="#d64541", marker="x", label="class 1")
    ax.set_title(title)
    ax.set_xlabel("feature 1")
axes[0].set_ylabel("feature 2")
axes[0].legend(loc="upper left")
plt.show()
print("majority rows kept:", int((y_under == 0).sum()))
Two scatter plots side by side on the same axes. On the left the training set shows a dense grey cloud, 1000 of its 3465 majority rows drawn, with 35 red crosses along its upper edge. On the right, after undersampling, only 35 scattered grey dots remain alongside the same 35 red crosses, and the shape of the majority cloud has gone.

The grey cloud on the right no longer has a shape. Thirty-five dots cannot describe where the majority class lives, so the boundary the model draws is decided by a handful of rows that happened to be picked.

Scoring a model trained on the smaller set

ExampleEvery minority row found, at a price
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, classification_report

model = LogisticRegression(max_iter=1000).fit(X_under, y_under)
pred = model.predict(X_test)

print(confusion_matrix(y_test, pred))
print()
print(classification_report(y_test, pred, digits=3))
  • Recall on class 1 went from 0.000 to 1.000. All 15 minority test rows are now flagged.
  • Precision on class 1 is about 0.07. For every real one found, roughly thirteen majority rows were flagged with it.
  • Accuracy fell from 0.990 to about 0.872, which is the right direction. The model is now doing something instead of nothing.

Choosing which majority rows to drop

Keeping a ratio instead of a 1 to 1 balance

sampling_strategy as a float is the ratio of minority to majority rows after resampling. 0.5 means the minority ends at half the majority size, so fewer rows are thrown away than a full balance needs.

python
half = RandomUnderSampler(sampling_strategy=0.5, random_state=42)
X_half, y_half = half.fit_resample(X_train, y_train)
# 35 minority rows stay, so the majority is cut to 70 rather than 35

Dropping by distance with NearMiss

NearMiss chooses majority rows by how close they are to minority rows instead of at random. Version 1, the default, keeps the majority rows with the smallest average distance to their three nearest minority rows, so the survivors sit on the boundary. It takes no random_state, because nothing about it is random.

python
from imblearn.under_sampling import NearMiss

near = NearMiss(version=1)        # NearMiss(random_state=42) raises TypeError
X_near, y_near = near.fit_resample(X_train, y_train)
ExampleFour training sets, one test set
from imblearn.under_sampling import NearMiss
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, accuracy_score

half = RandomUnderSampler(sampling_strategy=0.5, random_state=42)
near = NearMiss(version=1)

for name, sampler in [("none", None), ("RandomUnderSampler", under),
                      ("sampling_strategy=0.5", half), ("NearMiss v1", near)]:
    Xa, ya = (X_train, y_train) if sampler is None else sampler.fit_resample(X_train, y_train)
    p = LogisticRegression(max_iter=1000).fit(Xa, ya).predict(X_test)
    print(f"{name:22s} rows {len(ya):5d}  accuracy {accuracy_score(y_test, p):.4f}"
          f"  precision {precision_score(y_test, p, zero_division=0):.3f}"
          f"  recall {recall_score(y_test, p):.3f}")
  • Doing nothing scores best on accuracy and worst on recall, which is the trap What imbalance does to a model set out.
  • A 1 to 1 balance finds every minority row and raises the most false alarms, because the model was trained on a world that is half rare.
  • Halving the imbalance instead of removing it keeps the same recall on this dataset while flagging fewer majority rows. The ratio is a dial, not a switch.
  • NearMiss finds fewer minority rows than random dropping. Keeping only boundary rows throws away the easy majority rows that told the model where the safe region was.

RandomUnderSampler against NearMiss

RandomUnderSamplerNearMiss
How it picksUniformly at randomBy distance to minority rows
ReproducibleWith random_state=Always; it has no random step
What survivesA fair sample of the majorityMajority rows near the boundary
CostThe majority's shape is lostThe majority's safe interior is lost
SpeedInstantComputes nearest neighbours over the whole majority
Use it whenThe majority is large and you want a quick, honest cutYou want the hard cases kept and can afford the neighbour search

Where undersampling earns its place

  • The dataset is too large to train on. Ten million majority rows and five thousand minority rows: cutting the majority is the only way the fit finishes, and little is lost.
  • The majority rows are near duplicates. Logs and sensor readings repeat themselves, so dropping most of them costs almost no information.
  • You need a quick read on whether the signal exists at all. A balanced subsample trains in seconds and tells you if there is anything to find.
Watch out. Undersampling a small dataset throws away the only copy of information you have. Here it cut the training set from 3500 rows to 70. If the majority class is not repetitive and not huge, the class_weight argument gets the same effect without losing a single row.
Try it yourself
  • Set sampling_strategy=0.1 on RandomUnderSampler and rerun the comparison. Recall collapses, because the training set is nearly as imbalanced as before.
  • Swap NearMiss(version=1) for NearMiss(version=3) and compare the false alarms; version 3 keeps a different slice of the majority.
  • Change random_state on RandomUnderSampler from 42 to 7 and see how far precision and recall move on 35 training rows.

This is what real progress feels like.