Problem
Fraud, disease, machine failure, a click on an ad: the cases you most want to find are usually rare. Take a dataset of 1,000 transactions with 10 frauds. A model that answers "not fraud" every time is right on 99% of them, and it finds none of the frauds. Its recall is 0.
The training loss has the same blind spot. Cross-entropy averages over examples, so the 990 easy negatives outvote the 10 positives, and the cheapest way to lower the loss is to push every prediction toward "negative". The model can still rank the frauds above most legitimate transactions, yet at the default 0.5 cutoff it flags few of them. The data is not wrong. The loss and the threshold were set up for a balanced world.
Intuition
Picture a jury of 1,000 voices where 990 always say "innocent". To hear the other 10, you can make each of them louder (class weights), invite more of them or fewer of the others (oversampling or undersampling), or lower the bar for a "guilty" verdict (threshold moving). A fourth option quiets the voices that are already sure of themselves, so the uncertain ones carry the decision (focal loss).
The first three do nearly the same thing to a well-specified model: they shift its decision boundary toward the majority class. They differ in what they cost and in what happens to the predicted probabilities. Whatever you choose, judge it with metrics that look at the rare class directly: recall, precision, the precision–recall curve, balanced accuracy.
The "balanced" heuristic gives each class the same total say in the loss.
Mechanics
Why accuracy misleads
Accuracy is (TP + TN) / n. With positive rate π, the constant "negative" classifier scores 1 − π, so every accuracy number has to be read against that floor. Better summaries for a rare positive class (see classification metrics):
- Recall and precision of the rare class, and F1 or Fβ to combine them with a stated preference.
- Balanced accuracy, the mean of per-class recalls. The constant classifier scores 0.5 however rare the class is. scikit-learn's version equals accuracy with class-balanced sample weights.
- Average precision (area under the precision–recall curve). Its chance level is π, not 0.5, so it shows a rare-class ranker's quality where ROC AUC can look excellent while precision is poor.
- Per-class confusion matrices and cost-weighted error, when you know what each mistake costs.
Class weights
Give each example a weight si that depends on its class and minimize the weighted loss:
L(θ) = Σi syi · ℓ(fθ(xi), yi) balanced: sc = n / (K · nc)
With K classes and nc examples in class c, the "balanced" weights make every class's weights sum to n/K.
scikit-learn exposes this as class_weight="balanced" on most classifiers and multiplies it with any sample_weight.
PyTorch's CrossEntropyLoss(weight=…) takes one weight per class; with reduction="mean" it divides by the sum of the weights of the batch's targets,
not by the batch size. BCEWithLogitsLoss(pos_weight=p) multiplies only the positive term, −[p·y log σ(x) + (1 − y) log(1 − σ(x))], and its docstring
suggests p = negatives / positives. XGBoost's scale_pos_weight is the same idea for boosted trees, with the same suggested value.
Resampling
- Random oversampling repeats minority examples. Repeating each positive k times gives exactly the same loss as weighting it by k, so for a loss-minimizing model the two coincide. Duplicates do not add information, and models that can memorize (deep trees, large networks) overfit them.
- Random undersampling drops majority examples. Training is cheaper and the boundary moves the same way, but information is thrown away; repeating it over several subsamples and averaging (an ensemble) recovers some of it.
- SMOTE creates synthetic positives by interpolating between a positive and one of its positive nearest neighbours. It needs meaningful distances, so it suits low-dimensional numeric features better than text or images. It is not in scikit-learn itself.
- Sampling in a data loader. PyTorch's
WeightedRandomSamplerdraws indices with probability proportional to per-sample weights (by default with replacement). Weighting each example by 1/nc makes each class equally likely per draw: here a positive share of 50%.
Resample only the training split, inside each cross-validation fold. Resampling before the split puts copies of the same example on both sides, which is leakage.
Threshold moving and costs
A probabilistic classifier separates two decisions: estimating p = P(y = 1 | x), and acting on it. If a false positive costs cFP and a false negative cFN, predicting positive is cheaper in expectation when (1 − p)·cFP < p·cFN, that is when
p > t* = cFP / (cFP + cFN)
The default 0.5 assumes equal costs. Weighting positives by w is the same as multiplying their cost by w; for a logistic model it adds roughly log w
to the logit, which is equivalent to keeping the unweighted model and moving the threshold to 1/(1 + w). scikit-learn's FixedThresholdClassifier sets a cutoff by hand,
and TunedThresholdClassifierCV picks the one that maximizes a metric (balanced accuracy by default) across cross-validation folds. Threshold moving keeps the
probabilities calibrated; weighting and resampling do not.
Correcting probabilities after resampling
Keeping a fraction β of the negatives multiplies the odds of the positive class by 1/β, by Bayes' rule: the likelihoods are unchanged and only the prior ratio moves. Undo it by multiplying the odds back by β:
odds(p) = β · odds(ps) ⇒ p = β ps / (β ps − ps + 1)
The same correction with β = s0/s1 applies to a class-weighted model. Do this when downstream code needs real probabilities: expected cost, a risk score shown to a person, or a calibration check.
Focal loss
Lin et al. (2017) proposed focal loss for dense object detection, where a detector scores around 105 candidate boxes per image and almost all are easy background. Writing pt for the probability the model gives the true class,
FL(pt) = −αt (1 − pt)γ log pt
With γ = 0 and αt = 1 it is cross-entropy. The factor (1 − pt)γ shrinks the loss of examples the model already gets right, whatever their class, so
the gradient concentrates on hard examples. αt is an ordinary class weight. The paper's best setting was γ = 2 with α = 0.25 for the rare class, lower than you might
expect, because the focusing term already down-weights the many easy negatives. PyTorch has no focal-loss module at v2.15.0; it is a few lines on top of
binary_cross_entropy_with_logits (below).
Raising γ leaves hard examples (left) almost untouched and silences easy ones (right).
In the libraries
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
| Balanced class weights | compute_class_weight(class_weight, *, classes, y, sample_weight=None) → (n_classes,) |
n_samples / (n_classes * np.bincount(y)); the docstring cites King and Zeng (2001). |
class_weight.py 21-122 |
| Per-sample weights | compute_sample_weight(class_weight, y, *, indices=None) → (n_samples,) |
Expands class weights to one weight per row; indices computes "balanced" on a bootstrap subsample. |
class_weight.py 125-250 |
| Classifier option | LogisticRegression(class_weight=None) |
A dict or "balanced", multiplied with sample_weight in fit. |
_logistic.py 1077-1089 |
| Per-tree weights | RandomForestClassifier(class_weight="balanced_subsample") |
Recomputes the balanced weights on each tree's bootstrap sample. | _forest.py 1327-1351, 150-160 |
| Class-weighted cross-entropy | CrossEntropyLoss(weight=None, …, label_smoothing=0.0); weight (C,) |
ln = −wyn log softmax; the mean divides by Σ wyn. | loss.py 1200-1407 |
| Positive-class weight | BCEWithLogitsLoss(weight=None, …, pos_weight=None); pos_weight (C,) |
Scales the y log σ(x) term; p > 1 raises recall. Docstring: 100 positives and 300 negatives → 3. | loss.py 719-846 |
| Weighted sampling | WeightedRandomSampler(weights, num_samples, replacement=True, generator=None) |
Draws num_samples indices with torch.multinomial(weights, …); weights need not sum to 1. |
sampler.py 213-283 |
| Balanced accuracy | balanced_accuracy_score(y_true, y_pred, *, sample_weight=None, adjusted=False) |
Mean of diag(C) / row sums of the confusion matrix; adjusted rescales chance to 0. |
_classification.py 2845-2947 |
| Fixed threshold | FixedThresholdClassifier(estimator, *, threshold="auto", pos_label=None, response_method="auto") |
"auto" is 0.5 on predict_proba and 0 on decision_function. |
_classification_threshold.py 208-401 |
| Tuned threshold | TunedThresholdClassifierCV(estimator, *, scoring="balanced_accuracy", thresholds=100, cv=None, …) |
Scores 100 evenly spaced cutoffs on each fold, averages, and keeps the argmax as best_threshold_. |
_classification_threshold.py 704-821 |
| Boosted trees | scale_pos_weight [default=1] |
Balances positive and negative weights; suggested value Σ negatives / Σ positives. | parameter.rst 193-195 |
Worked example
The accuracy floor. 990 negatives and 10 positives. Always predicting "negative": accuracy 0.99, recall 0,
balanced accuracy 0.50. Balanced weights: 1000 / (2 × 990) = 0.5051 per negative and 1000 / (2 × 10) = 50 per positive.
pos_weight = 990 / 10 = 99.
A one-feature model. 180 negatives sit at evenly spaced quantiles of N(0, 1) and 20 positives at quantiles of N(2, 1), so π = 0.10. Each logistic regression below is fit by Newton's method to convergence (no penalty) and scored on the same 200 points:
| Model | w | b | Threshold | Cut at x | TP / FP / FN | Precision | Recall | Accuracy | Balanced acc. |
|---|---|---|---|---|---|---|---|---|---|
| Unweighted | 2.037 | −4.249 | 0.50 | 2.086 | 9 / 3 / 11 | 0.750 | 0.45 | 0.930 | 0.717 |
| Balanced weights (1 : 9) | 2.056 | −2.058 | 0.50 | 1.001 | 17 / 29 / 3 | 0.370 | 0.85 | 0.840 | 0.844 |
| Positives repeated 9× | 2.056 | −2.058 | 0.50 | 1.001 | 17 / 29 / 3 | 0.370 | 0.85 | 0.840 | 0.844 |
| Every 9th negative kept | 2.106 | −2.106 | 0.50 | 1.000 | 17 / 29 / 3 | 0.370 | 0.85 | 0.840 | 0.844 |
| Unweighted, threshold π | 2.037 | −4.249 | 0.10 | 1.007 | 17 / 28 / 3 | 0.378 | 0.85 | 0.845 | 0.847 |
- The unweighted model has the best accuracy, 0.93, and finds 9 of 20 positives. Its average precision is 0.676 against a chance level of 0.10: it ranks well, but its 0.5 cutoff sits at x = 2.09.
- Balanced weights and 9× oversampling give the same fit to every printed digit, as the algebra says. The slope barely moves (2.037 → 2.056); the intercept rises by 2.191, close to log 9 = 2.197. Recall goes from 0.45 to 0.85 and precision falls from 0.75 to 0.37.
- Moving the unweighted model's threshold to π = 1/(1 + 9) = 0.10 puts the cut at x = 1.007, next to the weighted model's 1.001, without refitting.
- Undersampling to 20 negatives (β = 0.1111) lands in the same place. At x = 1 it predicts 0.500; the correction βps / (βps − ps + 1) gives 0.100, close to the full-data model's 0.099.
Weighting shifts the curve left by about log 9 / w; moving the threshold reads the old curve lower. Both cut near x = 1.
Focal loss at pt = 0.9: what fraction of the cross-entropy loss is left for γ = 2?
(1 − 0.9)² = 0.01, so 0.0011 instead of 0.1054. At pt = 0.1 the factor is 0.81, so hard examples keep most of their loss.
Implementation
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader, WeightedRandomSampler
counts = torch.bincount(train_labels) # e.g. tensor([990, 10])
class_w = counts.sum() / (len(counts) * counts) # sklearn's "balanced"
loss_fn = torch.nn.CrossEntropyLoss(weight=class_w.float())
bce = torch.nn.BCEWithLogitsLoss(pos_weight=counts[0] / counts[1])
sample_w = (1.0 / counts.float())[train_labels] # or: rebalance batches instead
loader = DataLoader(train_ds, batch_size=64,
sampler=WeightedRandomSampler(sample_w, len(sample_w)))
def focal_loss(logits, targets, gamma=2.0, alpha=0.25):
ce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none")
p_t = torch.exp(-ce) # probability of the true class
alpha_t = alpha * targets + (1 - alpha) * (1 - targets)
return (alpha_t * (1 - p_t) ** gamma * ce).mean()
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import TunedThresholdClassifierCV
clf = LogisticRegression(class_weight="balanced").fit(X_train, y_train)
tuned = TunedThresholdClassifierCV(LogisticRegression(), scoring="f1").fit(X_train, y_train)
tuned.best_threshold_
Use one remedy at a time: a weighted loss on a rebalanced loader counts the correction twice. exp(−ce) recovers pt from the per-example
BCE, which is −log pt, without a separate sigmoid. The focal loss follows Lin et al.'s equation; alpha=0.25 and gamma=2.0 are the paper's chosen values, not a library default.
Tradeoffs
- Do you need a fix at all? A model that ranks well (high average precision) only needs its threshold set for your costs. Strong learners such as gradient boosting often rank well on imbalanced data without reweighting.
- Threshold moving is cheap, needs no retraining and keeps probabilities calibrated; tune it on validation data. It cannot help a model that never learned to separate the classes.
- Class weights change what the model learns, which matters when capacity is limited or regularization would otherwise flatten the minority. They distort probabilities (correct as above) and make the loss noisier: a few heavily weighted examples dominate each batch.
- Oversampling equals weighting for a fixed loss but costs more compute and invites memorization; undersampling is cheap and throws data away. SMOTE adds synthetic points that may be implausible and needs a sensible distance.
- Focal loss targets the "many easy negatives" case of dense detection; on ordinary tabular imbalance plain weighting is usually as good and simpler. It also miscalibrates probabilities.
- Evaluation pitfalls. Never resample the validation or test set; report metrics at the true base rate. Use stratified splits so every fold has positives (cross-validation). With very few positives, the metric itself is noisy: 20 positives move recall in steps of 0.05.
A balanced-weighted model predicts 0.6 for a case. Is the real probability 0.6?
No. Weighting positives by w relative to negatives inflates the odds by about w. Divide the odds by w: with w = 9, odds 1.5 become 1/6, a probability near 0.14.
A false negative costs 50 times a false positive. What threshold on a calibrated probability minimizes expected cost?
t* = cFP / (cFP + cFN) = 1/51 ≈ 0.02.
Connections
Read first
- Classification metrics intro
Read next
No page builds on this one yet.
The weighted loss is the cross-entropy with per-class multipliers; threshold moving is Bayes' decision rule from Bayes' rule with costs; the probability correction is the same prior-ratio update.
References
- King and Zeng. Logistic Regression in Rare Events Data. Political Analysis 9(2), 2001 (cited by scikit-learn for the "balanced" heuristic). doi:10.1093/oxfordjournals.pan.a004868
- Lin, Goyal, Girshick, He and Dollár. Focal Loss for Dense Object Detection. ICCV 2017. arXiv:1708.02002
- Chawla, Bowyer, Hall and Kegelmeyer. SMOTE: Synthetic Minority Over-sampling Technique. JAIR 16, 2002. arXiv:1106.1813
- Elkan. The Foundations of Cost-Sensitive Learning. IJCAI 2001 (the cost threshold and rebalancing by prior ratio).
- Brodersen, Ong, Stephan and Buhmann. The Balanced Accuracy and Its Posterior Distribution. ICPR 2010 (cited by scikit-learn's
balanced_accuracy_score). - scikit-learn 1.9.1:
sklearn/utils/class_weight.py,_classification_threshold.py,balanced_accuracy_score. - PyTorch v2.15.0:
torch/nn/modules/loss.py,torch/utils/data/sampler.py. - XGBoost v3.4.2:
doc/parameter.rst.