class_weight
class_weight is an argument on most scikit-learn classifiers that multiplies each class's share of the training error, so a rare class costs the model more to get wrong without a single row being copied or dropped.
Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9
Undersampling threw away 3430 rows and Oversampling and SMOTE invented 3430 more. Both changed the dataset to change the model's behaviour. There is a way to change the behaviour and leave the dataset alone, and on most problems it is the one to reach for first.
Syntax: one argument, either the string "balanced" or a dictionary of weights per class label.
LogisticRegression(class_weight="balanced") # weights computed from the counts
LogisticRegression(class_weight={0: 1, 1: 50}) # weights you choose
RandomForestClassifier(class_weight="balanced_subsample") # recomputed per treeReading the weights balanced computes
"balanced" sets each class's weight to n_samples / (n_classes * n_class_samples). A class holding 1 per cent of the rows gets a weight near 50, and the majority gets a weight near 0.5. The two classes then contribute the same total to the error, which is the same thing a 1 to 1 resample achieves, reached by arithmetic rather than by touching the data.
import numpy as np
from sklearn.utils.class_weight import compute_class_weight
weights = compute_class_weight("balanced", classes=np.array([0, 1]), y=y_train)import numpy as np
from collections import Counter
from sklearn.utils.class_weight import compute_class_weight
weights = compute_class_weight("balanced", classes=np.array([0, 1]), y=y_train)
print("training counts:", Counter(y_train.tolist()))
print("weight on class 0:", round(weights[0], 4))
print("weight on class 1:", round(weights[1], 4))
print()
print("by hand, class 1:", round(len(y_train) / (2 * (y_train == 1).sum()), 4))
print("each class now contributes:", round(weights[1] * (y_train == 1).sum(), 1))training counts: Counter({0: 3465, 1: 35})
weight on class 0: 0.5051
weight on class 1: 50.0
by hand, class 1: 50.0
each class now contributes: 1750.0- Class 1 gets a weight near 50, because it holds 1 per cent of the rows and there are two classes.
- Class 0 gets a weight near 0.5, slightly above half, since the majority is slightly under 99 per cent of the training half.
- Both classes end up contributing 1750, which is half the 3500 training rows each. That equality is what the word balanced means here.
- No row was copied or removed. The training set is still the 3465 and 35 rows the split produced.
Fitting with a weight instead of a resampler
from sklearn.linear_model import LogisticRegression
weighted = LogisticRegression(max_iter=1000, class_weight="balanced")
weighted.fit(X_train, y_train)from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, classification_report
weighted = LogisticRegression(max_iter=1000, class_weight="balanced")
weighted.fit(X_train, y_train)
pred = weighted.predict(X_test)
print(confusion_matrix(y_test, pred))
print()
print(classification_report(y_test, pred, digits=3))[[1381 104]
[ 0 15]]
precision recall f1-score support
0 1.000 0.930 0.964 1485
1 0.126 1.000 0.224 15
accuracy 0.931 1500
macro avg 0.563 0.965 0.594 1500
weighted avg 0.991 0.931 0.956 1500Recall on class 1 is 1.000 and accuracy is about 0.93, which is within a point or two of what a full SMOTE run gave. The difference is that this fit used the 3500 real rows it was given, and took one argument rather than a resampling step that has to be kept on the right side of every split.
Tuning the weight instead of accepting balanced
"balanced" is one point on a dial, and it is rarely the best one. A dictionary sets the weight directly, and sweeping it shows what each setting buys and what it costs.
for w in [1, 2, 5, 10, 20, 50, 99, 200]:
model = LogisticRegression(max_iter=1000, class_weight={0: 1, 1: w})
model.fit(X_train, y_train)import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, f1_score
weights = [1, 2, 5, 10, 20, 50, 99, 200]
precision, recall, f1 = [], [], []
for w in weights:
model = LogisticRegression(max_iter=1000, class_weight={0: 1, 1: w})
model.fit(X_train, y_train)
p = model.predict(X_test)
precision.append(precision_score(y_test, p, zero_division=0))
recall.append(recall_score(y_test, p))
f1.append(f1_score(y_test, p, zero_division=0))
plt.figure(figsize=(6, 4))
plt.plot(weights, recall, marker="o", color="#2e9e5b", label="recall")
plt.plot(weights, precision, marker="s", color="#3a6fd8", label="precision")
plt.plot(weights, f1, marker="^", color="#e08a1e", label="f1")
plt.xscale("log")
plt.xticks(weights, [str(w) for w in weights])
plt.xlabel("weight on class 1")
plt.ylabel("score on the test set")
plt.title("What the weight buys and what it costs")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
best = max(range(len(weights)), key=lambda i: f1[i])
print("best f1 at weight", weights[best], "->", round(f1[best], 4))best f1 at weight 10 -> 0.3889
- At weight 1 nothing is found. That is the untouched model from the start of this part.
- Recall is 0.933 by weight 10 and reaches 1.000 at weight 50. Most of what the balanced weight buys is bought a long way below it.
- Precision peaks near weight 10 and then falls. Past that point the extra weight buys no new minority rows and only adds false alarms.
- The best F1 of anything in this part is at weight 10, above every resampling result. A dial you can tune beats a switch you cannot.
Moving the cut-off instead of refitting
predict labels a row 1 when its predicted probability is at or above 0.5. On an imbalanced target that cut-off is usually wrong, and it can be changed without refitting anything. This is the cheapest of the three answers, because the model is already trained.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, f1_score
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
scores = model.predict_proba(X_test)[:, 1]
for cut in [0.5, 0.1, 0.05, 0.02]:
p = (scores >= cut).astype(int)
print(f"cut-off {cut:<5} flagged {int(p.sum()):4d}"
f" precision {precision_score(y_test, p, zero_division=0):.3f}"
f" recall {recall_score(y_test, p):.3f}"
f" f1 {f1_score(y_test, p, zero_division=0):.3f}")cut-off 0.5 flagged 0 precision 0.000 recall 0.000 f1 0.000 cut-off 0.1 flagged 39 precision 0.205 recall 0.533 f1 0.296 cut-off 0.05 flagged 73 precision 0.192 recall 0.933 f1 0.318 cut-off 0.02 flagged 166 precision 0.090 recall 1.000 f1 0.166
The model from What imbalance does to a model, the one that looked useless, finds most of the minority rows as soon as it is read at a lower cut-off. Its ROC AUC was about 0.98 all along. The 0.5 default was the only thing hiding it.
Resampling against class_weight
| Resampling | class_weight | |
|---|---|---|
| What changes | The rows the model sees | What an error on each row costs |
| Rows lost or invented | Yes, both are possible | None; every real row is kept |
| Extra step in a pipeline | Yes, and it must sit inside the fold | No, it is a constructor argument |
| Can leak into the test set | Yes, if it runs before the split | No, it is part of the fit |
| Tunable | Through sampling_strategy | Through the weight, with a grid search |
| Works with any estimator | Yes, it only changes the data | Only where the estimator accepts it |
| Reach for it when | The data is enormous, or the estimator takes no weight | Almost always; it is the first thing to try |
Where the resampler is still the right answer
- The estimator has no class_weight. Most boosting libraries use a single
scale_pos_weightnumber instead, and some models accept neither. - The majority is too large to train on. Dropping rows is then a speed decision that happens to fix the balance.
- The minority has structure you want filled in. SMOTE can help a distance-based model when the minority rows form a region rather than a scatter of unrelated cases.
class_weight changes the fit, not the metric. Accuracy on the weighted model still runs on the real, imbalanced test set, so it will look worse than the do-nothing model. That drop is the point, not a bug. Compare models on recall, precision and F1 for the rare class, and never on accuracy.Related
- Previous: Oversampling and SMOTE
- Next: Why features are dropped
- See also: What imbalance does to a model
- Reference: compute_class_weight in the scikit-learn API reference
- Add
class_weight="balanced"to aRandomForestClassifieron the same split and compare its recall with the logistic regression's. - Put
class_weightinto aGridSearchCVover[{0: 1, 1: w} for w in [5, 10, 20]]withscoring="f1"and see which weight it picks. - Print
scores.max()for the unweighted model and compare it with the 0.5 cut-off; the gap explains why it predicted nothing.
Little by little, you're building something great.