Skip to content
Home ML / AI Scikit-Learn Classification — OneHotEncoder Schema Drift

Scikit-Learn Classification — OneHotEncoder Schema Drift

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Scikit-Learn → Topic 5 of 8
Precision dropped 85% to 40%: OneHotEncoder handle_unknown missing.
⚙️ Intermediate — basic ML / AI knowledge assumed
In this tutorial, you'll learn
Precision dropped 85% to 40%: OneHotEncoder handle_unknown missing.
  • Scikit-Learn's unified API enables rapid algorithm swapping, but the real work is in building leakage-free pipelines.
  • Always wrap preprocessing and the model in a Pipeline to prevent data leakage from test statistics.
  • Accuracy is useless for imbalanced data—report F1, PR-AUC, and precision/recall instead.
Scikit-Learn Classification — Linear vs Non-Linear Models Comparison diagram: Linear models (Logistic Regression, LinearSVC) vs Non-linear models (Random Forest, SVM RBF, KNN, Gradient Boosting).THECODEFORGE.IOClassification Algorithms — When to Pick WhichChoosing the right classifier for your dataLinear ModelsNon-Linear ModelsVSLogistic Regression — fast, interpretableRandom Forest — robust, handles noiseLinearSVC — high-dim text dataSVM (RBF) — curved decision boundaryAssumes linear decision boundaryKNN — simple, no training phaseLow variance, may underfitGradient Boosting — highest accuracyGreat baseline — always try firstSlower, needs tuning, more dataTHECODEFORGE.IO
thecodeforge.io
Scikit-Learn Classification — Linear vs Non-Linear Models
Scikit Learn Classification
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Classification predicts discrete class labels from labelled training data — binary, multi-class, or multi-label
  • Scikit-Learn's unified fit/predict/predict_proba API lets you swap algorithms with one line change
  • Always wrap preprocessing + classifier in a Pipeline to prevent data leakage from test statistics contaminating training
  • Accuracy is misleading for imbalanced classes — use F1, PR-AUC, and ROC-AUC instead
  • Tune the decision threshold via predict_proba() + precision_recall_curve() — the default 0.5 is almost never optimal in production
  • XGBoost/LightGBM/CatBoost are what practitioners actually deploy for tabular data — they all implement the Scikit-Learn API
🚨 START HERE

Classification Pipeline Quick Debug

Immediate diagnostic commands for production classification issues
🟡

Model loaded but predict() crashes on new data

Immediate ActionCheck input schema matches training schema exactly
Commands
print(pipeline.named_steps['preprocessor'].feature_names_in_)
print(X_new.columns.tolist())
Fix NowAlign column names, types, and add missing columns with default values
🟡

All predictions are the same class

Immediate ActionCheck class distribution and decision threshold
Commands
print(y_train.value_counts(normalize=True))
print(pipeline.predict_proba(X_test)[:10])
Fix NowSet class_weight='balanced' or tune threshold via precision_recall_curve
🟡

Cross-validation F1 is 0.95 but test F1 is 0.60

Immediate ActionSuspect data leakage — check if preprocessors are fit outside the pipeline
Commands
cross_val_score(pipeline, X_train, y_train, cv=5, scoring='f1')
cross_val_score(clf, X_train_preprocessed, y_train, cv=5, scoring='f1')
Fix NowMove all preprocessing inside sklearn.pipeline.Pipeline — never pre-fit transformers
🟡

predict_proba returns extreme probabilities (all 0 or 1)

Immediate ActionModel is overconfident — check calibration
Commands
from sklearn.calibration import calibration_curve
frac_pos, mean_pred = calibration_curve(y_test, probs, n_bins=10)
Fix NowWrap model in CalibratedClassifierCV with cv=5 and method='isotonic'
Production Incident

Fraud model silently degraded after encoding schema drift

A fraud detection model's precision dropped from 85% to 40% overnight. Investigation revealed a new payment method appeared in production data that the OneHotEncoder had never seen during training.
SymptomFraud team reports a flood of false positives. Precision drops from 85% to 40% overnight. No code changes deployed.
AssumptionData distribution shifted due to a marketing campaign or seasonal pattern.
Root causeThe OneHotEncoder was not configured with handle_unknown='ignore'. A new payment method category appeared in production data. The encoder crashed or produced misaligned feature vectors, causing the model to output garbage predictions.
FixSet handle_unknown='ignore' on OneHotEncoder. Re-serialize the pipeline. Add a pre-prediction schema validation step that logs new categories without crashing.
Key Lesson
Always set handle_unknown='ignore' on OneHotEncoder in production pipelinesValidate input schema before prediction — log new categories as warningsMonitor prediction distribution daily — a sudden shift in predicted probabilities signals schema or distribution driftTest the loaded pipeline on a sample of production data before every deployment
Production Debug Guide

Common failure modes and immediate diagnostic steps for production classification systems

Model accuracy looks great but business metrics are terribleCheck class distribution — you likely have imbalanced classes and accuracy is dominated by the majority class. Switch to F1, PR-AUC, or a cost-weighted metric.
Model performance drops suddenly with no code changesCheck for input schema drift: new categories, renamed columns, changed data types. Run pipeline.predict() on a known-good sample to isolate whether it's a data issue or model corruption.
Cross-validation scores are high but test/production scores are lowYou likely have data leakage. Check if preprocessors (scaler, imputer, encoder) were fit before train/test splitting. Move everything into a Pipeline.
Model predicts all samples as the majority classClass imbalance problem. Set class_weight='balanced', tune the decision threshold down using precision_recall_curve, or apply SMOTE inside an imblearn pipeline.
Prediction latency spikes in productionCheck if you're using a large ensemble (500+ trees) or SVM with many support vectors. Profile with timeit. Consider switching to a faster model (LightGBM) or reducing n_estimators.

Classification predicts discrete class labels from labelled training data. Scikit-Learn provides a consistent, composable API for dozens of classification algorithms — from logistic regression to random forests — so you can swap algorithms, build preprocessing pipelines, evaluate performance rigorously, and tune hyperparameters without rewriting your code.

The algorithms are the easy part. The hard part is preventing data leakage, handling imbalanced classes, choosing the right evaluation metric, and building a pipeline you can serialize and deploy without surprises. This guide covers all of it — the algorithms, the gotchas, and the production patterns that separate a Jupyter notebook prototype from a reliable production system.

Here's the thing: most classification failures aren't about picking the wrong algorithm. They're about leaking test data into training, using accuracy on imbalanced data, or deploying a model that can't handle new categories. Get those right, and the algorithm choice often becomes a second-order concern.

What is Classification in Machine Learning?

Classification is a supervised learning task where the goal is to predict a discrete class label for each input. Supervised means you train on labelled examples — (features, label) pairs — where the correct label is known. The model learns the relationship between features and labels, then generalises to new inputs.

Binary classification has two classes (spam/not spam, fraud/not fraud, disease/healthy). Multi-class classification has three or more exclusive classes (cat, dog, bird). Multi-label classification assigns multiple labels per example (a news article can be both 'finance' and 'politics').

The output of a classifier is either a predicted class label (via predict()) or a probability distribution over all classes (via predict_proba()). Classification is distinct from regression, where the output is a continuous number.

The first question to ask before building any classifier: What does it cost to be wrong? If you misclassify spam, the user sees one extra email. If you misclassify a healthy patient as having cancer, they undergo unnecessary treatment. The cost of false positives vs false negatives determines your evaluation metric, your decision threshold, and your entire model selection strategy. Don't start with accuracy — start with the business impact of errors.

Here's a rule I've learned the hard way: if you don't know the cost of errors, you'll pick the wrong metric. And picking the wrong metric means you'll optimise the wrong thing. That's how you end up with a 99% accurate fraud model that catches zero fraud.

io/thecodeforge/ml/classification_basics.py · PYTHON
12345678910111213141516
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report

X, y = load_iris(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
)

clf = RandomForestClassifier(n_estimators=100, random_state=42)
clf.fit(X_train, y_train)

y_pred = clf.predict(X_test)
print(classification_report(y_test, y_pred, target_names=['setosa', 'versicolor', 'virginica']))
▶ Output
precision recall f1-score support
setosa 1.00 1.00 1.00 10
versicolor 1.00 1.00 1.00 10
virginica 1.00 1.00 1.00 10
accuracy 1.00 30
macro avg 1.00 1.00 1.00 30
weighted avg 1.00 1.00 1.00 30
Mental Model
Mental Model: Classification vs Regression
Classification answers 'which category?' — regression answers 'how much?'
  • Classification output: discrete label (spam/not spam) or probability distribution over classes
  • Regression output: continuous number (price, temperature)
  • The cost of errors drives everything — start with the business question, not the algorithm
  • predict() gives labels, predict_proba() gives probabilities — always prefer probabilities in production for threshold control
📊 Production Insight
Most production classification failures trace back to not asking 'what does it cost to be wrong?' before building.
A fraud model optimised for accuracy will predict 'not fraud' 99.5% of the time and score 99.5% accuracy while catching zero fraud.
Always define the cost matrix before choosing your evaluation metric.
I once spent two weeks tuning a model that was already perfect at predicting the majority class — the fix was a simple cost weight change.
🎯 Key Takeaway
Classification predicts discrete labels from labelled data.
The cost of false positives vs false negatives determines your metric, threshold, and algorithm — not the other way around.
Start every classification project by defining the business cost of errors.
Choosing the Right Classification Type
IfTwo mutually exclusive classes (spam/not spam)
UseBinary classification — use any classifier with predict_proba()
IfThree or more mutually exclusive classes (cat/dog/bird)
UseMulti-class classification — most classifiers handle this natively via one-vs-rest or softmax
IfMultiple labels per example (article tagged 'finance' AND 'politics')
UseMulti-label classification — use MultiOutputClassifier or MultiOutputChain wrappers

Scikit-Learn's Unified Estimator API — fit, predict, score

Scikit-Learn's biggest strength is its consistent API. Every classifier implements the same interface: fit(X, y) trains the model, predict(X) returns predicted labels, predict_proba(X) returns probability estimates, and score(X, y) returns mean accuracy.

This uniformity means you can swap algorithms with a single line change. The exact same preprocessing, splitting, and evaluation code works with LogisticRegression, RandomForestClassifier, SVC, or GradientBoostingClassifier.

The score() trap: score() returns accuracy by default for classifiers. For imbalanced datasets, accuracy is misleading — a model predicting the majority class every time scores 95% accuracy while being completely useless. Always use explicit metrics (F1, AUC) via sklearn.metrics rather than relying on score().

Think of it like this: score() is a shortcut for quick checks during development. But if you're using it to evaluate a model for production, you're making a mistake. You wouldn't check if a car works by looking at the colour — same idea.

io/thecodeforge/ml/unified_api.py · PYTHON
1234567891011121314151617181920
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC

classifiers = {
    'Logistic Regression': LogisticRegression(max_iter=1000),
    'Decision Tree':       DecisionTreeClassifier(max_depth=5),
    'Random Forest':       RandomForestClassifier(n_estimators=100),
    'Gradient Boosting':   GradientBoostingClassifier(n_estimators=100),
    'SVM':                 SVC(probability=True),
}

for name, clf in classifiers.items():
    clf.fit(X_train, y_train)
    acc = clf.score(X_test, y_test)
    print(f'{name}: {acc:.4f}')

probs = RandomForestClassifier().fit(X_train, y_train).predict_proba(X_test)
print(probs[0])
▶ Output
Logistic Regression: 0.9667
Decision Tree: 0.9333
Random Forest: 1.0000
Gradient Boosting: 1.0000
SVM: 0.9667
[0.02 0.07 0.91]
⚠ The score() Trap
score() returns accuracy by default for classifiers. For imbalanced datasets, accuracy is misleading — a model predicting the majority class every time scores 95% accuracy while being completely useless. Always use explicit metrics (F1, AUC) via sklearn.metrics rather than relying on score().
📊 Production Insight
The unified API enables rapid algorithm comparison — swap one line, keep all evaluation code identical.
Never rely on score() in production evaluation — it returns accuracy, which hides class imbalance problems.
Always use sklearn.metrics.f1_score, roc_auc_score, or average_precision_score explicitly.
I've seen teams proudly report 95% accuracy on a fraud dataset, then discover the model never predicted fraud once.
🎯 Key Takeaway
Scikit-Learn's unified fit/predict API lets you swap algorithms with one line change.
Never use score() for evaluation — it returns accuracy, which is meaningless on imbalanced data.
Always use explicit metrics from sklearn.metrics and prefer predict_proba() over predict() in production.
When to Use predict() vs predict_proba()
IfProduction binary classification
UseAlways use predict_proba() with a tuned threshold — never use the hardcoded 0.5 from predict()
IfMulti-class where you need confidence
UseUse predict_proba() to get the full probability distribution over all classes
IfQuick prototyping or evaluation only
Usepredict() is fine — but switch to predict_proba() before deployment

Feature Scaling — When to Scale and When Not to Bother

Feature scaling normalises the range of input features. Some algorithms are sensitive to feature scale; others are completely invariant.

Algorithms that REQUIRE scaling: SVM (distance-based), KNN (distance-based), Logistic Regression (gradient descent convergence), Neural Networks (gradient-based optimization). If you forget to scale for these, the feature with the largest range dominates the model.

Algorithms that DON'T need scaling: Decision Trees, Random Forests, Gradient Boosting, XGBoost, LightGBM, CatBoost. Tree-based models split on individual feature thresholds, so absolute scale doesn't matter.

Three scalers to know: StandardScaler (zero mean, unit variance — sensitive to outliers), MinMaxScaler (scales to [0,1] — for neural networks), RobustScaler (median and IQR — robust to outliers).

The production rule: If your pipeline includes SVM, KNN, or Logistic Regression, add StandardScaler. If it's tree-based only, skip scaling. If unsure, add it — it won't hurt tree models, just wastes a few milliseconds.

One more thing: don't just blindly scale everything. I've debugged cases where scaling a sparse binary feature caused the model to behave poorly. Know your data.

io/thecodeforge/ml/feature_scaling.py · PYTHON
12345678910111213141516171819202122
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC
from sklearn.neural_network import MLPClassifier

# SVM NEEDS scaling
svm_scaled = Pipeline([
    ('scaler', StandardScaler()),
    ('clf', SVC(kernel='rbf', probability=True)),
])

# When data has outliers, use RobustScaler
svm_robust = Pipeline([
    ('scaler', RobustScaler()),
    ('clf', SVC(kernel='rbf', probability=True)),
])

# For neural networks or bounded-input models
nn_scaled = Pipeline([
    ('scaler', MinMaxScaler()),
    ('clf', MLPClassifier(hidden_layer_sizes=(100, 50), max_iter=500)),
])
🔥Forge Tip: Scale Inside the Pipeline
Always put the scaler inside the Pipeline, not before it. If you scale before splitting, you leak test statistics into training. The Pipeline ensures the scaler is fit on training data only and applied to test data using training statistics.
📊 Production Insight
Forgetting to scale for SVM or KNN silently degrades performance — the model still trains but the feature with the largest numeric range dominates distance calculations.
I've seen SVM models with 60% accuracy jump to 92% simply by adding StandardScaler — no other changes.
Tree-based models (Random Forest, XGBoost) don't need scaling — adding it wastes a few milliseconds but doesn't hurt.
But be careful: scaling a sparse binary feature can break interpretation. Use domain knowledge.
🎯 Key Takeaway
SVM, KNN, Logistic Regression, and Neural Networks require feature scaling — tree-based models don't.
Always put the scaler inside the Pipeline, not before splitting — otherwise you leak test statistics into training.
Use RobustScaler when your data has outliers; StandardScaler is the default.
Feature Scaling Decision
IfUsing SVM, KNN, Logistic Regression, or Neural Networks
UseAdd StandardScaler (or RobustScaler if outliers present) inside the Pipeline
IfUsing tree-based models (RF, XGBoost, LightGBM, CatBoost)
UseSkip scaling — tree splits are invariant to feature scale
IfMixed pipeline with both tree and distance-based models
UseAdd scaling — it won't hurt tree models and is required for distance-based ones

Preprocessing Pipelines — The Right Way to Handle Feature Engineering

A pipeline chains preprocessing steps and a classifier into a single object. This is not optional convenience — it is the correct way to prevent data leakage.

Data leakage happens when information from the test set influences training. The classic mistake: fit a StandardScaler on the entire dataset before splitting. The scaler has 'seen' the test data and computed statistics from it. Your model was trained on a subtly contaminated version of reality.

With a Pipeline, fit() calls fit_transform() on preprocessors and fit() on the classifier — all on training data only. predict() calls transform() on preprocessors and predict() on the classifier. The test data is only transformed with statistics learned from training data.

The ColumnTransformer pattern: Real datasets have mixed feature types — numeric columns (age, income), categorical columns (country, plan_type). ColumnTransformer applies different preprocessing to different column subsets, all within the same pipeline. This is the standard pattern for production ML.

I'll say it again: if you're not using a Pipeline, you're almost certainly leaking data. I've seen it countless times.

io/thecodeforge/ml/pipeline_example.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestClassifier
import numpy as np
import pandas as pd

# Simulate a realistic dataset
df = pd.DataFrame({
    'age': [25, 45, np.nan, 30, 60],
    'income': [50000, 80000, 120000, np.nan, 90000],
    'country': ['US', 'UK', 'US', 'DE', 'FR'],
    'plan_type': ['basic', 'premium', 'basic', 'enterprise', 'premium'],
    'education': ['high_school', 'bachelors', 'masters', 'phd', 'bachelors'],
    'churned': [0, 0, 1, 0, 1],
})

X = df.drop('churned', axis=1)
y = df['churned']

numeric_features = ['age', 'income']
nominal_features = ['country', 'plan_type']
ordinal_features = ['education']

# Ordinal encoding for ordered categories
education_order = ['high_school', 'bachelors', 'masters', 'phd']

preprocessor = ColumnTransformer([
    ('num', Pipeline([('imputer', SimpleImputer(strategy='median')),
                      ('scaler', StandardScaler())]), numeric_features),
    ('nom', Pipeline([('imputer', SimpleImputer(strategy='most_frequent')),
                      ('onehot', OneHotEncoder(handle_unknown='ignore'))]), nominal_features),
    ('ord', Pipeline([('imputer', SimpleImputer(strategy='most_frequent')),
                      ('ordinal', OrdinalEncoder(categories=[education_order]))]), ordinal_features),
])

pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42)),
])

pipeline.fit(X, y)

# See transformed feature names
feature_names = pipeline.named_steps['preprocessor'].get_feature_names_out()
print('Transformed features:', feature_names)
▶ Output
Transformed features: ['num__age' 'num__income' 'nom__country_DE' 'nom__country_FR' 'nom__country_UK' 'nom__country_US' 'nom__plan_type_basic' 'nom__plan_type_enterprise' 'nom__plan_type_premium' 'ord__education']
🔥Forge Tip: handle_unknown='ignore' Is Non-Negotiable
Always set handle_unknown='ignore' on OneHotEncoder. In production, new data will contain categories not seen during training (a new country, a new plan type). Without this setting, the encoder crashes at prediction time. I've seen production models go down because a new category appeared in the data pipeline.
📊 Production Insight
Data leakage through pre-fitting preprocessors is the #1 silent killer of production ML models.
The model scores 95% in evaluation but performs at 70% in production — and the team spends weeks debugging the wrong thing.
ColumnTransformer + Pipeline is the non-negotiable pattern for any production classification system with mixed feature types.
And don't forget: if you don't set handle_unknown='ignore', a single new category in production will crash your entire pipeline.
🎯 Key Takeaway
Pipeline chains preprocessing and classifier into one object — it is the only correct way to prevent data leakage.
ColumnTransformer applies different preprocessing to different feature types within the same pipeline.
Always set handle_unknown='ignore' on OneHotEncoder — production data will contain unseen categories.
Preprocessing Strategy by Feature Type
IfNumeric features with missing values
UseSimpleImputer(strategy='median') + StandardScaler — median is robust to outliers
IfNominal categorical features (no order)
UseOneHotEncoder(handle_unknown='ignore') — never use OrdinalEncoder for unordered categories
IfOrdinal categorical features (education, priority)
UseOrdinalEncoder with explicit categories=[...] to control the ordering
IfHigh-cardinality categoricals (>50 unique values)
UseConsider target encoding or frequency encoding instead of OneHot to avoid feature explosion

Naive Bayes — The Baseline You Should Always Try First

Before reaching for Random Forest or XGBoost, try Naive Bayes. It's fast, simple, surprisingly effective, and serves as a strong baseline. If your complex model can't beat Naive Bayes, something is wrong with your features.

Three variants: GaussianNB (continuous features, assumes normal distribution), MultinomialNB (discrete count features like word counts or TF-IDF — the go-to for text classification), BernoulliNB (binary features — word present/absent).

Why it works for text: Text data is high-dimensional and sparse. Naive Bayes handles this gracefully because it assumes feature independence. This assumption is obviously wrong (words aren't independent), but it works shockingly well in practice.

The production baseline pattern: Always train a Naive Bayes model first. Report its metrics. Then train your fancy model. If the fancy model only marginally beats Naive Bayes, consider whether the added complexity is worth it. Naive Bayes trains in milliseconds and predicts in microseconds — that matters for real-time systems.

Here's a story: I once replaced a carefully tuned XGBoost model with a Naive Bayes model for a real-time ad classification system. The XGBoost was 1.2% more accurate but took 15x longer to predict. The business chose the faster model. Know your constraints.

io/thecodeforge/ml/naive_bayes.py · PYTHON
123456789101112131415161718192021
from sklearn.naive_bayes import GaussianNB, MultinomialNB, BernoulliNB
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report

# Gaussian Naive Bayes for continuous features
gnb = GaussianNB()
gnb.fit(X_train, y_train)
y_pred = gnb.predict(X_test)
print('GaussianNB:', classification_report(y_test, y_pred))

# Multinomial Naive Bayes for text (TF-IDF features)
text_pipeline = Pipeline([
    ('tfidf', TfidfVectorizer(max_features=5000, stop_words='english')),
    ('clf',   MultinomialNB(alpha=0.1)),
])

# Bernoulli Naive Bayes for binary features
bnb = BernoulliNB()
binary_features = (X_train > 0).astype(int)
bnb.fit(binary_features, y_train)
▶ Output
GaussianNB: precision recall f1-score support
0 0.96 0.94 0.95 50
1 0.83 0.88 0.85 22
accuracy 0.92 72
📊 Production Insight
Naive Bayes trains in milliseconds and predicts in microseconds — critical for real-time systems with latency budgets.
If your complex model only marginally beats Naive Bayes, the added complexity (maintenance, debugging, serialisation size) may not be worth it.
MultinomialNB + TF-IDF is the go-to baseline for text classification — it's surprisingly hard to beat without deep learning.
Sometimes the 'simple' model wins because it's easier to maintain and debug. Don't underestimate it.
🎯 Key Takeaway
Always train Naive Bayes first as a baseline — if your complex model can't beat it, something is wrong with your features.
MultinomialNB + TF-IDF is the classic text classification baseline that's fast, effective, and hard to beat.
Naive Bayes trains in milliseconds — in real-time production systems, this latency advantage matters.

Evaluating a Classifier — Beyond Accuracy

Accuracy is misleading for imbalanced classes. If 95% of data is class 0, a classifier predicting class 0 always achieves 95% accuracy while being completely useless.

Precision: Of all samples predicted positive, what fraction actually are? High precision = few false alarms. Recall: Of all actual positives, what fraction did we catch? High recall = few missed cases. F1: Harmonic mean of precision and recall.

The confusion matrix shows the full breakdown: TP, TN, FP, FN. For multi-class, it's an NxN matrix where the diagonal is correct predictions.

ROC-AUC measures discrimination ability across all thresholds. AUC 0.5 = random, 1.0 = perfect. PR-AUC (Precision-Recall AUC) is better for imbalanced data because it focuses on the positive class.

Which metric matters depends on the business cost: - Spam filtering: Precision matters. You don't want legitimate email in the spam folder. - Disease screening: Recall matters. You don't want to miss a sick patient. - Fraud detection: Recall matters more, but precision also matters because investigating false positives costs money.

The multi-metric approach: Always report at least three metrics: precision, recall, and F1. Add AUC if you use predicted probabilities. Never report accuracy alone for imbalanced problems.

I've had to tell more than one team that their 99% accurate model was useless. The confusion matrix showed they predicted 'not fraud' for everything. That's when you know you've been optimising the wrong thing.

io/thecodeforge/ml/evaluation_metrics.py · PYTHON
123456789101112131415161718192021222324252627282930313233
from sklearn.metrics import (
    classification_report, confusion_matrix,
    roc_auc_score, average_precision_score,
    ConfusionMatrixDisplay, precision_recall_curve
)
import matplotlib.pyplot as plt
import numpy as np

print(classification_report(y_test, y_pred))

cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot()
plt.title('Confusion Matrix')
plt.show()

# ROC-AUC for binary classification
probs = pipeline.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, probs)
print(f'ROC-AUC: {auc:.4f}')

# PR-AUC — better metric for imbalanced classes
pr_auc = average_precision_score(y_test, probs)
print(f'PR-AUC:  {pr_auc:.4f}')

# Custom class weights — when you know the business cost ratio
clf_cost_sensitive = RandomForestClassifier(
    class_weight={0: 1, 1: 100},
    random_state=42
)
clf_cost_sensitive.fit(X_train, y_train)
y_pred_cost = clf_cost_sensitive.predict(X_test)
print(classification_report(y_test, y_pred_cost))
▶ Output
precision recall f1-score support
0 0.98 0.96 0.97 50
1 0.82 0.91 0.86 22
accuracy 0.95 72
macro avg 0.90 0.93 0.91 72
weighted avg 0.94 0.95 0.94 72

ROC-AUC: 0.9834
PR-AUC: 0.9412
precision recall f1-score support
0 0.99 0.88 0.93 50
1 0.61 0.95 0.75 22
accuracy 0.90 72
macro avg 0.80 0.92 0.84 72
weighted avg 0.88 0.90 0.88 72
🔥Forge Tip: PR-AUC Over ROC-AUC for Imbalanced Data
For fraud detection, disease screening, or any high-stakes imbalanced classification, report PR-AUC alongside ROC-AUC. ROC-AUC can look deceptively good on imbalanced data because the true negative rate dominates. I've seen ROC-AUC of 0.95 drop to PR-AUC of 0.3 on a 1% fraud rate dataset — the model was mediocre at finding fraud but great at identifying the 99% obvious non-fraud cases.
📊 Production Insight
I've seen teams report 99% accuracy on a 1% fraud dataset — the model predicted 'not fraud' for everything.
The confusion matrix reveals what accuracy hides: how many fraud cases were missed (false negatives) and how many legitimate transactions were flagged (false positives).
Always report at least three metrics: precision, recall, and F1. Add PR-AUC for imbalanced problems.
And remember: no metric is perfect. Use multiple, and always connect them back to business costs.
🎯 Key Takeaway
Accuracy is misleading for imbalanced classes — a model predicting all negatives can score 95% while being useless.
Always report precision, recall, and F1. Add PR-AUC for imbalanced data — ROC-AUC can look deceptively good.
The business cost of false positives vs false negatives determines which metric to optimise.
Choosing the Right Evaluation Metric
IfBalanced classes, equal cost of errors
UseAccuracy is acceptable — but still report F1 for completeness
IfImbalanced classes, missing positives is costly (fraud, disease)
UsePrioritise recall and PR-AUC — optimise for catching positives
IfImbalanced classes, false positives are costly (spam filtering)
UsePrioritise precision — optimise for trustworthy positive predictions
IfNeed a single balanced metric
UseUse F1 score — harmonic mean of precision and recall

Decision Threshold Tuning — The Most Underrated Technique

Every binary classifier has a default decision threshold of 0.5: if predict_proba() returns >= 0.5, predict class 1. This threshold is arbitrary and almost never optimal for your specific business problem.

The insight: The threshold controls the trade-off between precision and recall. Lowering it catches more positives (higher recall) but flags more negatives as positives (lower precision). Raising it gives fewer but more trustworthy positive predictions.

How to find the optimal threshold: Use precision_recall_curve to get precision and recall at every possible threshold. Then choose the threshold that optimises your business metric.

In production: Don't use predict() — use predict_proba() and apply your own threshold. Store the threshold alongside the model. When business costs change, adjust the threshold without retraining.

I tuned the threshold on a fraud detection model from 0.5 to 0.15. Recall went from 60% to 92%. Precision dropped from 85% to 45%. The fraud team preferred catching 92% of fraud — the cost of missing fraud far exceeded the cost of investigating false positives. That's a business decision, not a technical one.

io/thecodeforge/ml/threshold_tuning.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940
import numpy as np
from sklearn.metrics import precision_recall_curve, f1_score

# Get probabilities
y_probs = pipeline.predict_proba(X_test)[:, 1]
precisions, recalls, thresholds = precision_recall_curve(y_test, y_probs)

# Find threshold that maximises F1
f1_scores = 2 * precisions[:-1] * recalls[:-1] / (precisions[:-1] + recalls[:-1] + 1e-8)
best_idx = np.argmax(f1_scores)
best_threshold = thresholds[best_idx]
print(f'Best threshold for F1: {best_threshold:.3f}')
print(f'F1 at best threshold: {f1_scores[best_idx]:.3f}')

# Apply custom threshold
y_pred_custom = (y_probs >= best_threshold).astype(int)
print(f'F1 with custom threshold: {f1_score(y_test, y_pred_custom):.4f}')

# Find threshold for minimum recall target
min_recall = 0.95
valid_indices = np.where(recalls[:-1] >= min_recall)[0]
if len(valid_indices) > 0:
    best_for_recall = valid_indices[np.argmax(precisions[valid_indices])]
    recall_threshold = thresholds[best_for_recall]
    print(f'Threshold for >= 95% recall: {recall_threshold:.3f}')

# Business cost minimisation
fn_cost = 1000  # missed fraud
fp_cost = 10    # false alarm
min_cost = float('inf')
best_cost_threshold = 0.5
for i, t in enumerate(thresholds):
    y_pred_t = (y_probs >= t).astype(int)
    fn = np.sum((y_test == 1) & (y_pred_t == 0))
    fp = np.sum((y_test == 0) & (y_pred_t == 1))
    cost = fn * fn_cost + fp * fp_cost
    if cost < min_cost:
        min_cost = cost
        best_cost_threshold = t
print(f'Threshold minimising business cost: {best_cost_threshold:.3f} (cost: ${min_cost:,.0f})')
▶ Output
Best threshold for F1: 0.340
F1 at best threshold: 0.831
F1 with custom threshold: 0.8314
Threshold for >= 95% recall: 0.180
Threshold minimising business cost: 0.150 (cost: $2,340)
⚠ Forge Warning: Never Use predict() in Production for Binary Classification
predict() uses a hardcoded 0.5 threshold. In production, always use predict_proba() and apply your own threshold. Store the threshold as a configuration parameter alongside the model. This single technique has saved me more production incidents than any algorithm choice.
📊 Production Insight
Tuning the threshold from 0.5 to 0.15 on a fraud model increased recall from 60% to 92% — no retraining required.
The threshold is a business decision, not a technical one. Store it as a config parameter alongside the model.
When business costs change (e.g., fraud losses spike), adjust the threshold without retraining — this takes seconds, not hours.
I've seen threshold tuning save more models than any algorithm change. It's the highest-ROI step you can take.
🎯 Key Takeaway
The default 0.5 threshold is arbitrary and almost never optimal — tune it using precision_recall_curve.
Always use predict_proba() in production, never predict() — store the threshold as a config parameter.
Threshold tuning is the highest-ROI technique: it improves model performance without retraining.
Threshold Tuning Strategy
IfBusiness requires minimum recall (e.g., catch 95% of fraud)
UseUse precision_recall_curve to find the threshold that achieves the target recall with maximum precision
IfBusiness has known cost per false positive and false negative
UseMinimise total cost = FN fn_cost + FP fp_cost across all thresholds
IfNo clear business requirement
UseMaximise F1 as the default balanced metric

Handling Imbalanced Classes — SMOTE, Thresholds, and Class Weights

Most real-world classification problems are imbalanced: fraud is rare, churn is rare, disease is rare. If you don't address this, your model will learn to predict the majority class every time.

1. class_weight='balanced': The simplest approach. Increases the loss contribution of minority class samples. Works with LogisticRegression, RandomForest, SVM. No extra dependencies. Try this first.

2. SMOTE (Synthetic Minority Oversampling): Generates synthetic minority class samples by interpolating between existing minority samples. pip install imbalanced-learn. Use SMOTEENN or SMOTETomek to clean noisy synthetic samples.

3. Threshold tuning: Use predict_proba() and tune the decision threshold (see previous section). Often the most effective approach because you keep the model unchanged — you just change the decision boundary.

The gotcha with SMOTE: Never apply SMOTE before train/test splitting. SMOTE generates synthetic samples based on nearest neighbours — if applied before splitting, synthetic test samples leak information about training samples. Always SMOTE inside a pipeline or after splitting.

imblearn.pipeline.Pipeline: scikit-learn's Pipeline doesn't support samplers (they lack transform()). Use imblearn.pipeline.Pipeline instead, which supports both transformers and samplers.

I've seen too many people apply SMOTE before splitting and claim an F1 of 0.95. When they fix it, it drops to 0.65. Don't be that person.

io/thecodeforge/ml/class_imbalance.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
from imblearn.over_sampling import SMOTE, ADASYN
from imblearn.under_sampling import RandomUnderSampler
from imblearn.combine import SMOTEENN
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score, StratifiedKFold
import numpy as np

# Strategy 1: class_weight (simplest)
clf_weighted = RandomForestClassifier(n_estimators=200, class_weight='balanced', random_state=42)

# Strategy 2: SMOTE inside imblearn pipeline
preprocessor = ColumnTransformer([
    ('num', Pipeline([('imp', SimpleImputer(strategy='median')), ('sc', StandardScaler())]), numeric_features),
    ('cat', Pipeline([('imp', SimpleImputer(strategy='most_frequent')), ('ohe', OneHotEncoder(handle_unknown='ignore'))]), categorical_features),
])

smote_pipeline = ImbPipeline([
    ('preprocessor', preprocessor),
    ('smote', SMOTE(random_state=42)),
    ('classifier', RandomForestClassifier(n_estimators=200, random_state=42)),
])

# Strategy 3: SMOTE + Edited Nearest Neighbours (cleans noisy samples)
smoteenn_pipeline = ImbPipeline([
    ('preprocessor', preprocessor),
    ('smoteenn', SMOTEENN(random_state=42)),
    ('classifier', RandomForestClassifier(n_estimators=200, random_state=42)),
])

# Strategy 4: Undersampling + SMOTE
combined_pipeline = ImbPipeline([
    ('preprocessor', preprocessor),
    ('under', RandomUnderSampler(sampling_strategy=0.5, random_state=42)),
    ('smote', SMOTE(random_state=42)),
    ('classifier', RandomForestClassifier(n_estimators=200, random_state=42)),
])

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for name, pipe in [('class_weight', clf_weighted),
                   ('SMOTE', smote_pipeline),
                   ('SMOTEENN', smoteenn_pipeline),
                   ('Under+SMOTE', combined_pipeline)]:
    scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring='f1')
    print(f'{name:15s} F1: {scores.mean():.4f} (+/- {scores.std():.4f})')
▶ Output
class_weight F1: 0.6234 (+/- 0.0234)
SMOTE F1: 0.6512 (+/- 0.0198)
SMOTEENN F1: 0.6634 (+/- 0.0187)
Under+SMOTE F1: 0.6589 (+/- 0.0201)
🔥Forge Tip: SMOTE Inside the Pipeline, Always
The most common SMOTE mistake is applying it before train/test splitting. This generates synthetic samples that blend training and test information. Always use imblearn.pipeline.Pipeline to ensure SMOTE is applied only to training folds during cross-validation. I've seen models with inflated F1 scores of 0.95 drop to 0.65 when SMOTE was moved inside the pipeline — the original score was an artifact of data leakage.
📊 Production Insight
class_weight='balanced' is the zero-dependency first step — try it before reaching for SMOTE.
SMOTE before splitting produces inflated metrics due to data leakage — always use imblearn.pipeline.Pipeline.
Threshold tuning is often more effective than SMOTE because it changes the decision boundary without altering the training data.
And remember: synthetic data is not real data. SMOTE can introduce noise if your minority samples are already noisy.
🎯 Key Takeaway
Handle class imbalance in order: class_weight='balanced' first, then threshold tuning, then SMOTE.
Never apply SMOTE before train/test splitting — always use imblearn.pipeline.Pipeline to prevent data leakage.
Threshold tuning is often more effective than resampling because it changes the decision boundary without altering training data.
Class Imbalance Strategy Selection
IfMild imbalance (minority class > 10%)
UseStart with class_weight='balanced' — no extra dependencies, often sufficient
IfSevere imbalance (minority class < 5%)
UseCombine class_weight + threshold tuning, or use SMOTE inside imblearn.pipeline.Pipeline
IfNoisy minority class samples
UseUse SMOTEENN or SMOTETomek to clean synthetic samples after oversampling

Cross-Validation — Reliable Performance Estimates

A single train/test split gives a noisy estimate. The specific random split affects which samples are in each set, and performance can vary significantly between splits. Cross-validation averages performance across multiple splits.

K-fold splits data into K folds, trains on K-1, validates on the remaining fold, rotates K times. StratifiedKFold preserves class proportions — always use it for classification.

cross_val_score returns test scores. cross_validate returns train and test scores plus timing — useful for diagnosing overfitting (train >> test = overfitting).

How many folds? 5 is the default. Use 10 for small datasets (more training data per fold). Use 3 for very large datasets (faster). The standard deviation across folds shows how stable the estimate is.

I once had a model that scored 0.95 on one random split and 0.72 on another. Cross-validation showed the true score was 0.83 with a standard deviation of 0.08. That's why we use CV.

io/thecodeforge/ml/cross_validation.py · PYTHON
12345678910111213141516
from sklearn.model_selection import cross_val_score, cross_validate, StratifiedKFold
from sklearn.ensemble import RandomForestClassifier

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
clf = RandomForestClassifier(n_estimators=100, random_state=42)

scores = cross_val_score(clf, X, y, cv=cv, scoring='f1_weighted')
print(f'F1: {scores.mean():.4f} (+/- {scores.std():.4f})')

results = cross_validate(clf, X, y, cv=cv, scoring=['accuracy', 'f1_weighted'], return_train_score=True)
print('Train F1:', results['train_f1_weighted'].mean())
print('Test  F1:', results['test_f1_weighted'].mean())

# CV with full pipeline — no leakage
cv_scores = cross_val_score(pipeline, X, y, cv=cv, scoring='roc_auc')
print(f'Pipeline AUC: {cv_scores.mean():.4f}')
▶ Output
F1: 0.9734 (+/- 0.0189)
Train F1: 0.9998
Test F1: 0.9734
Pipeline AUC: 0.9856
📊 Production Insight
A single train/test split can give wildly different results depending on the random seed — cross-validation stabilises the estimate.
Always use StratifiedKFold for classification to preserve class proportions in each fold.
The standard deviation across folds is your confidence interval — if it's high, your model is unstable or your data is too small.
I've seen models that looked amazing on one split and terrible on another. CV catches that.
🎯 Key Takeaway
Cross-validation gives a more reliable performance estimate than a single train/test split.
Always use StratifiedKFold for classification — it preserves class proportions.
High standard deviation across folds means your model is unstable — investigate before deploying.
🗂 Classifier Comparison for Common Scenarios
Key trade-offs when choosing between algorithms
AlgorithmTraining SpeedPrediction SpeedHandles Imbalance?Needs Scaling?InterpretabilityBest For
Logistic RegressionFastFastYes (class_weight)YesHighBaseline, online learning
Decision TreeFastFastPartial (depth bias)NoHighInterpretable rules
Random ForestModerateModerateYes (class_weight)NoMediumGeneral purpose, tabular
Gradient Boosting (XGBoost)SlowFastYes (scale_pos_weight)NoLowCompetitions, high accuracy
SVM (RBF kernel)SlowSlowYes (class_weight)YesLowSmall datasets, non-linear
Naive BayesVery FastVery FastNoNoHighText, real-time baselines
KNNNoneSlow (lazy)NoYesMediumSmall datasets, simple decision boundaries

🎯 Key Takeaways

  • Scikit-Learn's unified API enables rapid algorithm swapping, but the real work is in building leakage-free pipelines.
  • Always wrap preprocessing and the model in a Pipeline to prevent data leakage from test statistics.
  • Accuracy is useless for imbalanced data—report F1, PR-AUC, and precision/recall instead.
  • Tune the decision threshold using precision_recall_curve—the default 0.5 is almost never optimal.
  • Handle class imbalance with class_weight first, then threshold tuning, then SMOTE—never before splitting.
  • Use StratifiedKFold for cross-validation to get reliable performance estimates.
  • Set handle_unknown='ignore' on OneHotEncoder to avoid production crashes from unseen categories.

⚠ Common Mistakes to Avoid

    Fitting preprocessors before train/test split
    Symptom

    Cross-validation scores are much higher than test scores (e.g., CV F1=0.95, test F1=0.60). The scaler or imputer has 'seen' the test data and leaked information into training.

    Fix

    Move all preprocessing into an sklearn.pipeline.Pipeline so that fit() is only called on training data and transform() is applied to test data using training statistics.

    Using accuracy for imbalanced classification
    Symptom

    Model reports 99.5% accuracy but catches zero fraud cases. The confusion matrix shows all predictions are majority class.

    Fix

    Switch to F1 score, precision/recall, or PR-AUC. Always check class distribution first.

    Forgetting handle_unknown='ignore' on OneHotEncoder
    Symptom

    Pipeline crashes on new data with an error like 'ValueError: Found unknown categories'. Or model silently degrades because categories are misaligned.

    Fix

    Set OneHotEncoder(handle_unknown='ignore'). Optionally add a log warning when unknown categories appear.

    Using predict() instead of predict_proba() in production
    Symptom

    Model performance is suboptimal; you cannot adjust the decision threshold without retraining.

    Fix

    Always use predict_proba() and apply a business-specific threshold. Store the threshold as a config parameter.

    Applying SMOTE before splitting the dataset
    Symptom

    Cross-validation scores are suspiciously high (e.g., F1=0.95) but test score drops dramatically (F1=0.65). Synthetic samples have leaked across folds.

    Fix

    Use imblearn.pipeline.Pipeline to ensure SMOTE is applied inside each fold's training data only.

Interview Questions on This Topic

  • QExplain the difference between precision, recall, and F1-score. When would you prioritise precision over recall?JuniorReveal
    Precision is the fraction of true positives among all predicted positives (TP / (TP + FP)). Recall is the fraction of true positives among all actual positives (TP / (TP + FN)). F1 is the harmonic mean of precision and recall (2 P R / (P + R)). Prioritise precision when false positives are costly—for example, spam filtering (you don't want legitimate email in spam). Prioritise recall when false negatives are costly—for example, disease screening (you don't want to miss a sick patient).
  • QHow would you handle extreme class imbalance (1% positive class) in a fraud detection pipeline? Walk through the steps.Mid-levelReveal
    Step 1: Check data quality and ensure no data leakage. Step 2: Start with class_weight='balanced' on a Random Forest or Logistic Regression. Step 3: Tune the decision threshold using precision_recall_curve, targeting the recall that matches business requirements. Step 4: If that's not enough, try SMOTE inside an imblearn.pipeline.Pipeline (never before split). Step 5: Evaluate using PR-AUC and F1 on the positive class—never accuracy. Step 6: Consider cost-sensitive training if you have a cost matrix. In production, monitor prediction distribution and schema drift.
  • QDesign a production classification system that can handle schema drift, has interpretable predictions, and can be retrained without downtime.SeniorReveal
    I'd use a Pipeline with ColumnTransformer and set handle_unknown='ignore' on OneHotEncoder. Add a schema validation step before prediction that logs new categories as warnings. For interpretability, integrate SHAP's TreeExplainer and store the explanations alongside predictions. For retraining, implement a rolling window retraining pipeline: train a new model in parallel on a weekly schedule, validate it against a holdout set, and if performance doesn't degrade, swap the model by replacing the serialized Pipeline pickle (or ONNX). Use canary deployment: route 5% of traffic to the new model and monitor prediction distribution and drift metrics (e.g., PSI). If metrics are stable, gradually roll out. Store all thresholds as config parameters so they can be adjusted without retraining.

Frequently Asked Questions

What is the difference between classification and regression?

Classification predicts discrete class labels (e.g., spam or not spam). Regression predicts continuous values (e.g., house price). Scikit-Learn provides separate estimators for each task, but the API is identical (fit/predict/score).

When should I use Random Forest vs XGBoost?

Random Forest is simpler, trains faster, and has fewer hyperparameters to tune. Use it as a strong baseline. XGBoost typically achieves higher accuracy but requires hyperparameter tuning (learning rate, max_depth, subsample). In production, XGBoost is more common due to its performance on tabular data. Start with Random Forest, then try XGBoost if you need the extra edge.

How do I handle missing values in a classification pipeline?

Use SimpleImputer inside the Pipeline. For numeric features, strategy='median' is robust to outliers. For categorical features, strategy='most_frequent' fills with the most common category. Never drop rows with missing values in production—your pipeline must handle them gracefully.

What is data leakage and how do I prevent it?

Data leakage happens when information from the test set (or future data) influences training. Common causes: fitting a scaler on the whole dataset before splitting, applying SMOTE before splitting, or using future data for feature engineering. Prevent it by placing all preprocessing inside a Pipeline so that fit() is only called on the training set.

Why is my model's accuracy high but its business impact low?

Accuracy is dominated by the majority class. If 95% of your data is 'not fraud', a model that predicts 'not fraud' every time scores 95% accuracy but catches zero fraud. Switch to precision, recall, F1, or PR-AUC to evaluate performance on the minority class. Then tune the decision threshold to match business priorities.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousLinear Regression with Scikit-LearnNext →Feature Engineering and Preprocessing in Scikit-Learn
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged