Skip to main content
Scikit-Learn · lesson 6 of 12
Scikit-Learn intermediate Lesson 6 of 12

Scikit-Learn Classification

Train and compare logistic regression, random forests, gradient boosting and SVMs in scikit-learn, then tune the decision threshold without leaking.

By SkillByExample · Updated

Classification predicts a category: spam or not, fraud or not, which of three species. Every example here ran with scikit-learn 1.9.1, mostly on the built-in breast cancer dataset (569 tumours, 30 features, benign or malignant).

A Logistic Regression Baseline

Logistic regression is fast and interpretable, which makes it the right first model.

from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)

model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
model.fit(X_train, y_train)
print(classification_report(y_test, model.predict(X_test), digits=3))
print(f"ROC AUC: {roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]):.4f}")
$ python baseline.py
              precision    recall  f1-score   support

           0      0.976     0.976     0.976        42
           1      0.986     0.986     0.986        72

    accuracy                          0.982       114
   macro avg      0.981     0.981     0.981       114
weighted avg      0.982     0.982     0.982       114

ROC AUC: 0.9954

In this dataset class 0 is malignant and class 1 is benign. Recall for class 0 is the share of malignant tumours the model caught, which matters more here than overall accuracy. ROC AUC uses the probabilities rather than the labels, so it measures how well the model ranks malignant above benign across every possible threshold.

Random Forest and Feature Importance

A random forest averages many decision trees trained on random subsets of rows and features.

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, data.target, test_size=0.2, random_state=42, stratify=data.target)

rf = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
print(f"test accuracy: {rf.score(X_test, y_test):.4f}")

top = np.argsort(rf.feature_importances_)[::-1][:5]
for i in top:
    print(f"  {data.feature_names[i]:22s} {rf.feature_importances_[i]:.3f}")
$ python random_forest.py
test accuracy: 0.9561
  worst perimeter        0.133
  worst area             0.128
  worst concave points   0.108
  mean concave points    0.094
  worst radius           0.091

No scaling was needed, since trees split on thresholds. feature_importances_ measures how much each feature reduced impurity across all trees. It is a quick first look, but it favours high-cardinality features and spreads credit across correlated ones, such as the several “worst” size measurements here.

Comparing Classifiers Fairly

A single split can flatter one model by luck. Cross-validation scores each model on five different held-out folds.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = load_breast_cancer(return_X_y=True)
models = {
    "logistic": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
    "forest": RandomForestClassifier(random_state=0, n_jobs=-1),
    "hist_gb": HistGradientBoostingClassifier(random_state=0),
    "svm_rbf": make_pipeline(StandardScaler(), SVC()),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
for name, model in models.items():
    s = cross_val_score(model, X, y, cv=cv, scoring="roc_auc")
    print(f"{name:9s} AUC {s.mean():.4f} +/- {s.std():.4f}")
$ python compare.py
logistic  AUC 0.9955 +/- 0.0056
forest    AUC 0.9923 +/- 0.0070
hist_gb   AUC 0.9940 +/- 0.0053
svm_rbf   AUC 0.9957 +/- 0.0046

All four are close, and the spread between folds is about as large as the gaps between models. On a small, clean dataset like this, the simple linear model is as good as the ensembles. Gradient boosting tends to pull ahead on larger, messier tables. SVC works with scoring="roc_auc" without probability=True because the scorer falls back to its decision_function.

predict vs predict_proba

predict returns labels; predict_proba returns the probabilities behind them.

from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)

rows = X[[0, 19, 541]]
print("labels:       ", model.predict(rows))
print("P(benign):    ", model.predict_proba(rows)[:, 1].round(3))
$ python proba.py
labels:        [0 1 0]
P(benign):     [0.    0.926 0.454]

The first two rows are confident calls, while the third sits close to the 0.5 boundary. The label hides that difference; the probability exposes it, which is what you need to route uncertain cases to a human or to move the threshold.

The Accuracy Trap on Imbalanced Data

When positives are rare, accuracy stops meaning much. A baseline that always predicts the majority class shows how much.

from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import accuracy_score, recall_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=5000, weights=[0.95, 0.05], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

for model in [DummyClassifier(strategy="most_frequent"),
              HistGradientBoostingClassifier(random_state=0)]:
    pred = model.fit(X_train, y_train).predict(X_test)
    print(f"{type(model).__name__:31s} accuracy {accuracy_score(y_test, pred):.3f}"
          f"  recall(positive) {recall_score(y_test, pred):.3f}")
$ python imbalance_trap.py
DummyClassifier                 accuracy 0.945  recall(positive) 0.000
HistGradientBoostingClassifier  accuracy 0.957  recall(positive) 0.473

The dummy scores 94.5% accuracy while catching zero positives. The real model beats it by barely a point of accuracy, and its recall shows it still misses about half of the rare class, a weakness accuracy alone would hide. On imbalanced problems, report recall, precision or F1 for the class you care about.

Tuning the Decision Threshold

The default 0.5 threshold is rarely best for a rare class. TunedThresholdClassifierCV picks a threshold by cross-validation on the training rows only.

from sklearn.datasets import make_classification
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score
from sklearn.model_selection import TunedThresholdClassifierCV, train_test_split

X, y = make_classification(n_samples=5000, weights=[0.95, 0.05], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

base = HistGradientBoostingClassifier(random_state=0).fit(X_train, y_train)
tuned = TunedThresholdClassifierCV(HistGradientBoostingClassifier(random_state=0),
                                   scoring="f1", cv=5).fit(X_train, y_train)

print(f"threshold 0.50  test F1 {f1_score(y_test, base.predict(X_test)):.3f}")
print(f"threshold {tuned.best_threshold_:.2f}  test F1 {f1_score(y_test, tuned.predict(X_test)):.3f}")
$ python threshold.py
threshold 0.50  test F1 0.547
threshold 0.07  test F1 0.603

The tuned threshold was chosen without ever seeing the test rows, so its test F1 is an honest estimate. Picking the threshold that maximises F1 on the test set itself, a common shortcut, would report a number the model can’t reproduce on new data.

Practice

1. Add class_weight="balanced" to a logistic regression on the imbalanced data and compare positive-class recall.
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=5000, weights=[0.95, 0.05], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

for cw in [None, "balanced"]:
    pred = LogisticRegression(class_weight=cw).fit(X_train, y_train).predict(X_test)
    print(f"class_weight={str(cw):8s} recall {recall_score(y_test, pred):.3f}"
          f"  precision {precision_score(y_test, pred):.3f}")
$ python p1_balanced.py
class_weight=None     recall 0.364  precision 0.667
class_weight=balanced recall 0.782  precision 0.232

Weighting the rare class more heavily trades precision for recall: the model flags more rows as positive, catching more real positives along with more false alarms.

2. Print a confusion matrix for the baseline logistic regression.
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X_train, y_train)
print(confusion_matrix(y_test, model.predict(X_test)))
$ python p2_confusion.py
[[41  1]
 [ 1 71]]

Rows are true classes and columns are predictions, in label order (0 = malignant, 1 = benign). The off-diagonal cells are the mistakes; the top-right cell counts malignant tumours predicted benign, the costly error in this domain.

3. Tune the threshold for balanced accuracy instead of F1 and compare recall and precision.
from sklearn.datasets import make_classification
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import TunedThresholdClassifierCV, train_test_split

X, y = make_classification(n_samples=5000, weights=[0.95, 0.05], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

tuned = TunedThresholdClassifierCV(HistGradientBoostingClassifier(random_state=0),
                                   scoring="balanced_accuracy", cv=5).fit(X_train, y_train)
pred = tuned.predict(X_test)
print(f"threshold {tuned.best_threshold_:.3f}")
print(f"recall {recall_score(y_test, pred):.3f}  precision {precision_score(y_test, pred):.3f}")
$ python p3_recall_threshold.py
threshold 0.010
recall 0.727  precision 0.494

Balanced accuracy averages the recall of both classes, so maximising it pushes the threshold down and raises recall on the rare class at the cost of precision. The metric you tune for decides which errors you accept.

Frequently Asked Questions

How do I choose between logistic regression, random forest and gradient boosting?
Start with logistic regression as a fast, interpretable baseline. Try a random forest when relationships are non-linear and you want good results with little tuning, and gradient boosting such as HistGradientBoostingClassifier when you want the best accuracy on tabular data. Compare them with cross-validation on your own data rather than trusting a general ranking.
What is the difference between predict and predict_proba?
predict returns a class label, while predict_proba returns one probability per class for each row. For a binary classifier, predict is simply predict_proba above 0.5. Use the probabilities when you need a confidence score or want a decision threshold other than 0.5.
Why is accuracy misleading on imbalanced data?
When 95% of rows belong to one class, a model that always predicts that class scores 95% accuracy while catching none of the rare cases. Precision, recall, F1 and ROC AUC for the minority class reveal the problem. Always compare against a DummyClassifier baseline.
How do I tune the decision threshold without overfitting?
Pick the threshold on validation data, never on the test set. TunedThresholdClassifierCV, added in scikit-learn 1.5, wraps a classifier and chooses the threshold that maximises a metric such as F1 using cross-validation on the training data only, so the test score stays an honest estimate.