# Imbalanced Classification

> Imbalance is not itself a problem — it is a symptom that the default loss and the

- Skill: `snoodleboot-io/imbalanced-classification` (Agent Skill)
- Install (CLI): `npx skillmds@latest add snoodleboot-io/imbalanced-classification`
- Raw SKILL.md: https://api.skillmd.com/api/skills/snoodleboot-io/imbalanced-classification/raw
- Safety review: PASS (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: snoodleboot-io (https://skillmd.com/u/snoodleboot-io)
- Updated: 2026-08-19
- Page: https://skillmd.com/skills/snoodleboot-io/imbalanced-classification

---


# Imbalanced Classification (Verbose)

## Core Patterns

### Diagnose Before You Treat

Imbalance is not itself a problem — it is a symptom that the default loss and the
default threshold are misaligned with the cost structure. Establish three numbers
before touching a sampler:

1. **Prevalence.** 1:10 rarely needs intervention. 1:1000 changes the whole approach.
2. **Absolute positive count.** 500 positives out of 500,000 is workable; 20 positives
   out of 20,000 is a problem of data volume, not of balance, and no resampler fixes it.
3. **Relative cost.** A missed fraud costing $400 against a review costing $5 gives an
   80:1 ratio that directly implies the operating threshold.

```python
import numpy as np
counts = np.bincount(y)
print(counts, counts[1] / counts.sum())   # e.g. [99000  1000] 0.01
```

### Metric Selection

```python
from sklearn.metrics import (average_precision_score, roc_auc_score,
                             precision_recall_curve, confusion_matrix)

p = model.predict_proba(X_test)[:, 1]
print("PR-AUC ", average_precision_score(y_test, p))   # baseline = prevalence
print("ROC-AUC", roc_auc_score(y_test, p))             # baseline = 0.5
```

The baselines differ in a way that matters. A random classifier scores 0.5 ROC-AUC but
only 0.01 PR-AUC at 1% prevalence, so a PR-AUC of 0.25 — unimpressive-looking — is a
25× lift over chance. Report PR-AUC against prevalence, not against 0.5.

| Situation | Primary metric |
|---|---|
| Ranking for a fixed-capacity review queue | Precision@k, PR-AUC |
| Screening where misses are dangerous | Recall at a precision floor |
| Costs known in currency | Expected cost at the tuned threshold |
| Comparing across datasets of differing prevalence | ROC-AUC (prevalence-invariant) |
| Model selection under strong imbalance | PR-AUC |

Always print the confusion matrix at the deployed threshold. Aggregate scores hide
whether the model produces 40 false positives or 4,000.

### Cost-Sensitive Learning

The cheapest effective intervention is reweighting the loss, which leaves the data
untouched and therefore cannot leak.

```python
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
import xgboost as xgb

LogisticRegression(class_weight="balanced", max_iter=1000)
RandomForestClassifier(class_weight="balanced_subsample")
xgb.XGBClassifier(scale_pos_weight=(y == 0).sum() / (y == 1).sum())
```

`"balanced"` sets `w_c = n_samples / (n_classes * n_c)`. For explicit control pass a
dict — `class_weight={0: 1, 1: 80}` — derived from the actual cost ratio rather than
from the frequency ratio. Those two ratios are different quantities and are only
accidentally equal.

### Resampling

| Method | Mechanism | Cost | Notes |
|---|---|---|---|
| `RandomUnderSampler` | Drop majority rows | Cheap, fast | Discards signal; good at huge n |
| `RandomOverSampler` | Duplicate minority rows | Cheap | Trees can overfit exact duplicates |
| `SMOTE` | Interpolate k-NN minority pairs | Moderate | Needs meaningful distances |
| `BorderlineSMOTE` | Interpolate near the boundary | Moderate | Often better than plain SMOTE |
| `SMOTENC` | SMOTE with categorical columns | Moderate | Required for mixed dtypes |
| `SMOTEENN` | Oversample, then clean noise | Expensive | Aggressive; verify it helps |

SMOTE assumes Euclidean neighbourhoods are meaningful. On one-hot encoded
high-cardinality categoricals, on unscaled features, or in more than ~50 dimensions,
interpolation produces points that lie off the data manifold — synthetic records that
no real process could generate. Scale first, or use `SMOTENC`, or skip it.

```python
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from sklearn.model_selection import cross_val_score, StratifiedKFold

pipe = Pipeline([
    ("over", SMOTE(sampling_strategy=0.1, random_state=0)),   # minority to 10% of majority
    ("under", RandomUnderSampler(sampling_strategy=0.5, random_state=0)),
    ("clf", RandomForestClassifier(n_estimators=300, n_jobs=-1)),
])
scores = cross_val_score(pipe, X, y, cv=StratifiedKFold(5), scoring="average_precision")
```

Note `sampling_strategy=0.1` rather than full balance. Resampling all the way to 1:1 is
a habit, not a requirement; moderate rebalancing frequently outperforms it and distorts
probabilities less. Treat the ratio as a hyperparameter.

### Threshold Optimization

```python
import numpy as np
from sklearn.metrics import precision_recall_curve

prec, rec, thr = precision_recall_curve(y_val, p_val)

# Highest recall subject to precision >= 0.30
ok = prec[:-1] >= 0.30
threshold = thr[ok][np.argmax(rec[:-1][ok])]
```

Or optimize expected cost directly, which is the honest version when the numbers exist:

```python
def expected_cost(t, cost_fn=400, cost_fp=5):
    pred = (p_val >= t).astype(int)
    fn = ((pred == 0) & (y_val == 1)).sum()
    fp = ((pred == 1) & (y_val == 0)).sum()
    return fn * cost_fn + fp * cost_fp

grid = np.linspace(0.01, 0.99, 99)
best = grid[np.argmin([expected_cost(t) for t in grid])]
```

Choose the threshold on validation data and freeze it. Selecting it on the test set is
the same leakage as tuning any other hyperparameter there.

### Calibration

Resampling and class weighting shift the predicted probability distribution: a model
trained on artificially balanced data outputs values near a 50% base rate rather than
the true 1%. Ranking is preserved, so AUC is unaffected — but any downstream expected-value
calculation is now wrong.

```python
from sklearn.calibration import CalibratedClassifierCV

clf = CalibratedClassifierCV(base_model, method="isotonic", cv=5)
clf.fit(X_train, y_train)     # fit on the real, unresampled distribution
```

Use `method="sigmoid"` (Platt scaling) when calibration data is scarce — isotonic needs
roughly 1,000+ samples and will overfit below that.

## Common Anti-Patterns

❌ **Reporting accuracy on 99:1 data.** The trivial constant classifier wins.
✅ PR-AUC, plus precision/recall at the deployed threshold.

❌ **`X_res, y_res = SMOTE().fit_resample(X, y)` before splitting** — synthetic points
interpolated from validation rows land in training.
✅ `imblearn.pipeline.Pipeline`, which resamples only during `fit`.

❌ **Resampling the test set** to "make evaluation fair" — the test set must reflect
production prevalence or precision is meaningless.
✅ Resample training only; evaluate at the real base rate.

❌ **Shipping the 0.5 threshold** because `predict()` uses it.
✅ Tune the threshold on validation against the cost structure.

❌ **Optimizing ROC-AUC at 1:1000** and being surprised that 96% of alerts are false.
✅ Optimize PR-AUC or precision@k, which react to false positives.

❌ **Always resampling to exactly 1:1.**
✅ Sweep `sampling_strategy`; compare against plain `class_weight="balanced"` first.

❌ **SMOTE on one-hot categoricals or unscaled features** — interpolation invents
impossible records.
✅ `SMOTENC` for mixed types, scaling before any distance-based sampler.

❌ **Treating 20 positives as an imbalance problem.**
✅ It is a data-collection problem — gather more labels, or use anomaly detection.

## Imbalanced Classification Checklist

- [ ] Prevalence, absolute positive count, and cost ratio all measured
- [ ] Primary metric is PR-AUC / precision@k, never accuracy
- [ ] PR-AUC compared against the prevalence baseline
- [ ] Class weighting tried before any resampler
- [ ] Resamplers live inside `imblearn.pipeline.Pipeline`
- [ ] Validation and test sets left at production prevalence
- [ ] `sampling_strategy` tuned, not fixed at 1:1
- [ ] `SMOTENC` used when categorical features are present
- [ ] Decision threshold chosen on validation from the cost structure
- [ ] Confusion matrix at the deployed threshold reviewed
- [ ] Probabilities calibrated if consumed as probabilities
- [ ] Stratified splits used throughout
- [ ] Alert volume checked against downstream review capacity

