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 →

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.

Three columns comparing the answers to an imbalanced target. The first, change the data, uses resamplers such as RandomUnderSampler, RandomOverSampler and SMOTE, and rows are dropped or invented. The second, change the loss, uses class_weight set to balanced or to a dictionary, and every real row is kept while one argument changes. The third, change the cut-off, uses predict_proba and a lower threshold, and the fitted model is not refitted at all. A panel underneath notes that all three move the same dial, more of the rare class found against more false alarms raised, and that none of them adds information.

Syntax: one argument, either the string "balanced" or a dictionary of weights per class label.

python
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 tree

Reading 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.

python
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)
ExampleWhere the numbers come from
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))
  • 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

python
from sklearn.linear_model import LogisticRegression

weighted = LogisticRegression(max_iter=1000, class_weight="balanced")
weighted.fit(X_train, y_train)
ExampleThe same training rows, one extra argument
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))

Recall 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.

python
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)
ExampleOne argument swept from 1 to 200
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))
A line chart with the weight on class 1 on a logarithmic x axis from 1 to 200. Recall rises from 0 at weight 1 to 1.0 by weight 50 and stays there. Precision rises to a peak around weight 10 and then falls steadily. The F1 line peaks at the same place as precision and declines on both sides of it.
  • 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.

ExampleThe untouched model, read at four cut-offs
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}")

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

Resamplingclass_weight
What changesThe rows the model seesWhat an error on each row costs
Rows lost or inventedYes, both are possibleNone; every real row is kept
Extra step in a pipelineYes, and it must sit inside the foldNo, it is a constructor argument
Can leak into the test setYes, if it runs before the splitNo, it is part of the fit
TunableThrough sampling_strategyThrough the weight, with a grid search
Works with any estimatorYes, it only changes the dataOnly where the estimator accepts it
Reach for it whenThe data is enormous, or the estimator takes no weightAlmost 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_weight number 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.
Watch out. 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.
Try it yourself
  • Add class_weight="balanced" to a RandomForestClassifier on the same split and compare its recall with the logistic regression's.
  • Put class_weight into a GridSearchCV over [{0: 1, 1: w} for w in [5, 10, 20]] with scoring="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.