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.
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 placeOlder material passes the ratio as the first positional argument. That has not worked for several releases, and the error says exactly why.
from imblearn.under_sampling import RandomUnderSampler
RandomUnderSampler(0.75)Traceback (most recent call last):
File "main.py", line 3, in <module>
RandomUnderSampler(0.75)
TypeError: RandomUnderSampler.__init__() takes 1 positional argument but 2 were givenRandomUnderSampler(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.
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.
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)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()))training set before: Counter({0: 3465, 1: 35})
training set after: Counter({0: 35, 1: 35})
test set, untouched: Counter({0: 1485, 1: 15})- 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
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()))majority rows kept: 35
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
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))[[1293 192]
[ 0 15]]
precision recall f1-score support
0 1.000 0.871 0.931 1485
1 0.072 1.000 0.135 15
accuracy 0.872 1500
macro avg 0.536 0.935 0.533 1500
weighted avg 0.991 0.872 0.923 1500- 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.
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 35Dropping 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.
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)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}")none rows 3500 accuracy 0.9900 precision 0.000 recall 0.000 RandomUnderSampler rows 70 accuracy 0.8720 precision 0.072 recall 1.000 sampling_strategy=0.5 rows 105 accuracy 0.9173 precision 0.108 recall 1.000 NearMiss v1 rows 70 accuracy 0.9507 precision 0.096 recall 0.467
- 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
| RandomUnderSampler | NearMiss | |
|---|---|---|
| How it picks | Uniformly at random | By distance to minority rows |
| Reproducible | With random_state= | Always; it has no random step |
| What survives | A fair sample of the majority | Majority rows near the boundary |
| Cost | The majority's shape is lost | The majority's safe interior is lost |
| Speed | Instant | Computes nearest neighbours over the whole majority |
| Use it when | The majority is large and you want a quick, honest cut | You 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.
Related
- Previous: What imbalance does to a model
- Next: Oversampling and SMOTE
- See also: class_weight
- Reference: Under-sampling in the imbalanced-learn user guide
- Set
sampling_strategy=0.1onRandomUnderSamplerand rerun the comparison. Recall collapses, because the training set is nearly as imbalanced as before. - Swap
NearMiss(version=1)forNearMiss(version=3)and compare the false alarms; version 3 keeps a different slice of the majority. - Change
random_stateonRandomUnderSamplerfrom 42 to 7 and see how far precision and recall move on 35 training rows.
This is what real progress feels like.