Overfitting & Underfitting — ML Bias-Variance Tradeoff

Photo: www.kaboompics.com / Pexels
The two most common reasons machine learning models fail in production: overfitting and underfitting. Every ML practitioner needs to recognize these patterns and know how to fix them.
This guide explains both concepts in plain English, the bias-variance tradeoff that connects them, and the practical techniques that actually work.
The Core Idea
When you train an ML model:
- Underfitting: Model is too simple. Misses patterns in the data. Performs poorly on training AND test sets.
- Overfitting: Model is too complex. Memorizes training data including noise. Performs well on training but poorly on test set.
- Just right: Model captures real patterns but generalizes to new data. Good performance on both training and test.
Underfit: Train accuracy 70%, Test accuracy 68% ← model too weak
Overfit: Train accuracy 99%, Test accuracy 71% ← model memorized
Good: Train accuracy 92%, Test accuracy 89% ← generalizes well
The gap between train and test accuracy is the biggest signal.
Visual Intuition
Imagine fitting a curve through data points:
Underfit (straight line through curved data):
y
| • •
| • • •
|---/--------------------
| / Misses the curve
| /
+--------------- x
Overfit (zigzag connecting every point):
y
| /\ /\ /\
| / \ / \ / \
|/ \/ \/ \
| Memorizes noise
+--------------- x
Good fit (smooth curve following real pattern):
y
| ___---___
| __/ \__
| _/ \_
| / \
+--------------- x
How to Detect Overfitting
Signs of overfitting:
- Training accuracy significantly higher than test accuracy (gap > 5-10%)
- Performance degrades on real-world data after deployment
- Loss decreases on training but increases on validation during training
- Tiny changes in data produce wildly different predictions
Detection code:
from sklearn.model_selection import cross_val_score
train_score = model.score(X_train, y_train)
test_score = model.score(X_test, y_test)
cv_scores = cross_val_score(model, X_train, y_train, cv=5)
print(f"Train: {train_score:.3f}")
print(f"Test: {test_score:.3f}")
print(f"CV: {cv_scores.mean():.3f} ± {cv_scores.std():.3f}")
# Red flags:
# - Train >> Test (overfit)
# - High CV std (unstable performance)
How to Detect Underfitting
Signs of underfitting:
- Both training and test accuracy are low
- Adding more data doesn't help much
- The model can't capture obvious patterns
- Training error plateaus quickly at a high value
Detection:
# Plot training curves
import matplotlib.pyplot as plt
from sklearn.model_selection import learning_curve
train_sizes, train_scores, test_scores = learning_curve(
model, X, y, cv=5, train_sizes=[0.1, 0.3, 0.5, 0.7, 1.0]
)
plt.plot(train_sizes, train_scores.mean(axis=1), label='Train')
plt.plot(train_sizes, test_scores.mean(axis=1), label='Test')
plt.legend()
plt.show()
# Underfit: both curves flat and low
# Overfit: big gap between curves
# Good: curves converge at high value
The Bias-Variance Tradeoff
The mathematical framework behind under/overfitting:
- Bias: Error from oversimplified assumptions. High bias = underfitting.
- Variance: Error from sensitivity to small fluctuations in training data. High variance = overfitting.
Total Error = Bias² + Variance + Irreducible Noise
You can't make both bias and variance zero. The art is finding the right balance for your data.
Examples:
| Model | Bias | Variance |
|---|---|---|
| Linear regression | High | Low |
| Decision tree (deep) | Low | High |
| Random forest | Lower bias, lower variance (averaging helps) | |
| Deep neural network (untuned) | Low | Very high |
| Deep neural network (regularized) | Low | Lower |
How to Fix Overfitting
1. Get More Training Data
The single best fix. If overfitting is severe and you can't get more data, every other technique is a workaround.
2. Reduce Model Complexity
# Random Forest
RandomForestClassifier(max_depth=5) # Lower depth = simpler
# XGBoost
XGBClassifier(max_depth=4, n_estimators=200) # Smaller, fewer trees
# Neural network
# Remove layers or reduce neurons per layer
3. Regularization
Add penalties for complexity:
from sklearn.linear_model import Ridge, Lasso
# L2 regularization (Ridge)
ridge = Ridge(alpha=1.0) # Higher alpha = more regularization
# L1 regularization (Lasso) — also does feature selection
lasso = Lasso(alpha=0.1)
For neural networks:
# Dropout — randomly zero out neurons during training
nn.Dropout(p=0.5)
# Weight decay (L2 regularization)
optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
4. Early Stopping
Stop training when validation loss starts increasing:
# XGBoost
model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
early_stopping_rounds=20 # Stop if no improvement in 20 rounds
)
# Neural networks (PyTorch)
best_loss = float('inf')
patience = 10
patience_counter = 0
for epoch in range(100):
train_loss = train_one_epoch()
val_loss = validate()
if val_loss < best_loss:
best_loss = val_loss
patience_counter = 0
save_model()
else:
patience_counter += 1
if patience_counter >= patience:
break # Early stop
5. Data Augmentation (Especially for Images)
Create variations of training examples:
# Image augmentation
from torchvision import transforms
transform = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.ToTensor(),
])
6. Ensemble Methods
Combine multiple models to reduce variance:
from sklearn.ensemble import VotingClassifier
ensemble = VotingClassifier([
('rf', RandomForestClassifier()),
('xgb', XGBClassifier()),
('lr', LogisticRegression())
])
Random Forest itself is an ensemble — and naturally resists overfitting.
How to Fix Underfitting
1. Use a More Powerful Model
# Replace linear regression with XGBoost
from xgboost import XGBRegressor
model = XGBRegressor(n_estimators=500)
# Add layers/parameters to neural network
2. Add More Features
# Feature engineering
df['feature_squared'] = df['feature'] ** 2
df['feature_log'] = np.log1p(df['feature'])
df['interaction'] = df['feature1'] * df['feature2']
df['ratio'] = df['feature1'] / (df['feature2'] + 1)
Effective feature engineering can introduce patterns the model can learn, addressing the root cause of underfitting.
3. Reduce Regularization
If you've set regularization too high:
Ridge(alpha=0.01) # Was alpha=10
# Less penalty = model can fit better
4. Train Longer
For neural networks, underfitting can mean you didn't train enough:
# Increase epochs from 10 to 100
for epoch in range(100):
train_one_epoch()
5. Use Different Algorithm Entirely
If a simple model can't capture your patterns, switch to a more flexible one:
- Linear models → Tree-based (Random Forest, XGBoost)
- Tree-based → Neural networks (for unstructured data)
Advanced Regularization Techniques
Ridge vs. Lasso: The Practical Difference
Ridge (L2) and Lasso (L1) regularization approach penalty differently:
- Ridge (L2): Penalizes the sum of squared weights. Shrinks all weights toward zero but rarely makes any exactly zero. Good when all features might be relevant.
- Lasso (L1): Penalizes the sum of absolute weights. Can shrink some weights to exactly zero, performing automatic feature selection. Good when you suspect many features are irrelevant.
# Ridge with varying alpha
from sklearn.linear_model import Ridge
alphas = [0.001, 0.01, 0.1, 1, 10, 100]
for alpha in alphas:
ridge = Ridge(alpha=alpha).fit(X_train, y_train)
print(f"Alpha {alpha}: Train {ridge.score(X_train, y_train):.3f}, Test {ridge.score(X_test, y_test):.3f}")
# Elastic Net: combination of Ridge + Lasso
from sklearn.linear_model import ElasticNet
elastic = ElasticNet(alpha=0.1, l1_ratio=0.5) # 50% L1, 50% L2
Elastic Net mixes L1 and L2 penalties, allowing you to get feature selection (from L1) while maintaining ridge's stability (from L2).
Source: Hastie, Tibshirani, & Friedman (2009) — The Elements of Statistical Learning: Data Mining, Inference, and Prediction.
Cross-Validation: The Key Tool
Don't just look at one train/test split. Use cross-validation to get reliable estimates:
from sklearn.model_selection import KFold, cross_val_score
# 5-fold CV
scores = cross_val_score(model, X, y, cv=5, scoring='accuracy')
print(f"CV: {scores.mean():.3f} ± {scores.std():.3f}")
If CV std is high (>0.05), the model is unstable — likely overfitting on different folds.
Stratified cross-validation preserves class distribution in each fold (crucial for imbalanced datasets):
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, test_idx in skf.split(X, y):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# Train and evaluate
Nested cross-validation protects against overfitting to the validation set itself:
from sklearn.model_selection import cross_val_score, GridSearchCV
# Outer loop: model evaluation on truly held-out data
outer_cv = KFold(n_splits=5)
inner_cv = KFold(n_splits=3)
scores = []
for train_idx, test_idx in outer_cv.split(X):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# Inner loop: hyperparameter tuning on train set only
gs = GridSearchCV(model, param_grid, cv=inner_cv)
gs.fit(X_train, y_train)
scores.append(gs.best_estimator_.score(X_test, y_test))
Source: Refaeilzadeh, Tang, & Liu (2009) — Cross-validation techniques for estimating the performance of machine learning algorithms.
Learning Curves and Model Capacity
Learning curves plot model performance (both training and validation) as a function of training set size:
from sklearn.model_selection import learning_curve
train_sizes = np.linspace(0.1, 1, 10)
train_sizes_abs, train_scores, val_scores = learning_curve(
model, X, y, cv=5,
train_sizes=train_sizes,
scoring='accuracy'
)
import matplotlib.pyplot as plt
plt.plot(train_sizes_abs, train_scores.mean(axis=1), label='Train')
plt.plot(train_sizes_abs, val_scores.mean(axis=1), label='Validation')
plt.xlabel('Training Set Size')
plt.ylabel('Accuracy')
plt.legend()
plt.show()
Interpreting learning curves:
- Both curves low and flat: Underfitting. Adding more data won't help; model is too simple.
- Curves diverging (train high, val low): Overfitting. Model complexity is the issue.
- Curves converging at high value: Good fit. Model generalizes; more data might still help slightly.
- Curves converging at low value with gap: Irreducible error or fundamental data issue.
The Double Descent Phenomenon
Recent research has upended classical understanding of the bias-variance tradeoff. Double descent describes a counter-intuitive phenomenon where model test error first decreases (traditional bias-variance), then increases at the point of perfect fitting (traditional overfitting), then decreases again as the model becomes sufficiently overparameterized.
This suggests that:
- A model with exactly as many parameters as training samples (n=p) has high test error
- But a model with many more parameters (p >> n) can generalize well if trained properly (especially with early stopping or implicit regularization)
This explains why modern deep learning works: overparameterized networks trained with SGD + early stopping often generalize excellently despite having far more parameters than training examples.
Source: Belkin, Bartlett, & Hastie (2019) — Reconciling Modern Machine Learning and the Bias-Variance Trade-Off, Proceedings of the National Academy of Sciences.
Bayesian Perspectives on Overfitting
From a Bayesian viewpoint, overfitting occurs when the model posterior (our belief about the model after seeing data) assigns high probability to models that don't reflect the true data-generating process.
Bayesian regularization uses a prior distribution over weights, penalizing complex models directly:
# PyMC3 Bayesian linear regression
import pymc3 as pm
with pm.Model() as model:
# Priors — set belief before seeing data
beta = pm.Normal('beta', mu=0, sd=1, shape=X.shape[1])
sigma = pm.HalfNormal('sigma', sd=1)
# Likelihood
mu = X @ beta
y_obs = pm.Normal('y_obs', mu=mu, sd=sigma, observed=y)
# Posterior inference
trace = pm.sample(2000, return_inferencedata=True)
The strength of Bayesian regularization is that it naturally incorporates uncertainty: you get distributions of predictions, not point estimates, which quantifies doubt about uncertain regions of data space.
Diagnosing the Problem: A Decision Tree
When your model underperforms, use this systematic approach:
Measure training and test accuracy
- If both low → likely underfitting
- If train high, test low → likely overfitting
- If both high → success
If underfitting:
- Increase model complexity (more features, deeper trees, more layers)
- Reduce regularization strength
- Check if training longer helps
If overfitting:
- Get more training data (best fix if possible)
- Reduce model complexity
- Increase regularization strength
- Use ensemble methods
Verify with learning curves:
- Flat at low accuracy → underfitting
- Diverging curves → overfitting
- Converging at high accuracy → good fit
Real-World Case Study: Image Classification
You're training a CNN on medical images (only 500 examples) and get:
- Training accuracy: 97%
- Validation accuracy: 62%
- Clear overfitting
Fixes applied in order:
- Add data augmentation (rotations, flips, brightness shifts) → Val acc rises to 72%
- Reduce model complexity (fewer conv layers, fewer filters) → Val acc rises to 78%
- Add dropout (p=0.5) → Val acc rises to 81%
- Use transfer learning (pre-trained ImageNet backbone, fine-tune) → Val acc rises to 88%
- Ensemble with other models → Final val acc 90%
Each step is motivated by the overfitting diagnosis and targets a specific mechanism.
Source: Stanford CS231n: Convolutional Neural Networks for Visual Recognition, lecture notes on regularization techniques.
The Right Train/Test/Validation Split
from sklearn.model_selection import train_test_split
# First split: train + temp
X_train, X_temp, y_train, y_temp = train_test_split(
X, y, test_size=0.3, random_state=42
)
# Second split: val + test
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.5, random_state=42
)
# Result: 70% train, 15% validation, 15% test
Critical rule: Never look at the test set until the very end. Use validation set for all hyperparameter tuning.
Real Example: Solving Overfitting
Starting model:
Train accuracy: 99%
Test accuracy: 72%
Gap: 27% → severe overfitting
Step 1: Add cross-validation
CV mean: 0.71, std: 0.08 → unstable
Step 2: Reduce model complexity (max_depth from 20 to 6)
Train: 89%, Test: 85% → much better
Step 3: Add regularization (alpha=0.1)
Train: 87%, Test: 86% → minimal gap, stable
Step 4: Validate on held-out test set
Final test accuracy: 86% → ship it
Class Imbalance and Overfitting: A Hidden Connection
Class imbalance — when one class vastly outnumbers others (e.g., fraud detection: 99.5% legitimate, 0.5% fraudulent) — creates a subtle form of overfitting. The model learns to achieve high accuracy by simply predicting the majority class, ignoring the minority.
Why it happens: In imbalanced datasets, the model's loss function is dominated by the majority class. Standard accuracy metrics mask poor minority-class performance:
Model: "Always predict majority class"
Accuracy: 99.5% ← looks great!
But: Sensitivity (catch fraud): 0% ← useless
Detection with stratified metrics:
from sklearn.metrics import precision_recall_curve, roc_auc_score
from sklearn.model_selection import StratifiedKFold
# Use ROC-AUC instead of accuracy
roc_auc = roc_auc_score(y_test, y_pred_proba)
# Use precision-recall curve
precision, recall, thresholds = precision_recall_curve(y_test, y_pred_proba)
# Confusion matrix breakdown
from sklearn.metrics import confusion_matrix
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
sensitivity = tp / (tp + fn) # True positive rate
specificity = tn / (tn + fp) # True negative rate
Fixes for imbalanced data:
Stratified sampling: Use
StratifiedKFoldwhen splitting—ensures each fold has representative class proportions.Class weighting: Penalize minority-class errors more heavily:
# XGBoost
scale_pos_weight = (n_majority / n_minority)
model = XGBClassifier(scale_pos_weight=scale_pos_weight)
# Sklearn
class_weight = {0: 1, 1: (n_majority / n_minority)}
model = LogisticRegression(class_weight=class_weight)
- Resampling:
- Oversampling: Duplicate minority samples (risk of overfitting to duplicates).
- Undersampling: Remove majority samples (loss of information).
- SMOTE (Synthetic Minority Oversampling): Generate synthetic minority examples.
from imblearn.over_sampling import SMOTE
smote = SMOTE()
X_balanced, y_balanced = smote.fit_resample(X_train, y_train)
- Threshold tuning: Adjust decision threshold instead of retraining:
# Default threshold is 0.5; try 0.3 for minority class
from sklearn.metrics import precision_recall_curve
precision, recall, thresholds = precision_recall_curve(y_test, y_pred_proba)
# Choose threshold based on business needs
optimal_threshold = 0.3
y_pred_adjusted = (y_pred_proba >= optimal_threshold).astype(int)
Source: He, X. & Geng, Y. (2016) — Learning from Imbalanced Data, IEEE Transactions on Knowledge and Data Engineering.
Dimensionality Reduction as an Anti-Overfitting Tool
High-dimensional data (many features) creates opportunity for overfitting: with thousands of features and only hundreds of samples, models find spurious correlations that don't generalize.
Dimensionality reduction removes noise and redundancy, keeping only the most informative features or learned components:
PCA (Principal Component Analysis): Finds orthogonal directions of maximum variance:
from sklearn.decomposition import PCA
# Reduce from 100 to 10 dimensions
pca = PCA(n_components=10)
X_reduced = pca.fit_transform(X_train)
print(f"Explained variance: {pca.explained_variance_ratio_.sum():.3f}")
# Output: 0.95 = 10 components capture 95% of variance
Feature selection: Remove irrelevant or redundant features directly:
from sklearn.feature_selection import SelectKBest, f_classif
selector = SelectKBest(f_classif, k=10) # Keep top 10 features
X_selected = selector.fit_transform(X_train, y_train)
# Get selected feature names
selected_features = X.columns[selector.get_support()].tolist()
Autoencoder bottleneck: For deep learning, compress through a bottleneck layer:
# Input (100 dims) → Hidden (50 dims) → Bottleneck (10 dims) → Hidden (50) → Output (100)
# The 10-dim bottleneck forces compression, learning a low-dim representation
When to use:
- High-dimensional data (more features than samples)
- Noisy sensors or redundant features
- Computation cost is high
- Want interpretable feature importance
Trade-off: Dimensionality reduction loses information. Use cross-validation to confirm it doesn't hurt model performance.
Source: Goodfellow, Bengio, & Courville (2016) — Deep Learning, Chapter 5: Regularization for Deep Learning.
Robustness and Adversarial Stability
An overlooked form of overfitting is adversarial overfitting: the model fits the training data so tightly that tiny perturbations in input cause massive prediction changes.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.neighbors import KNeighborsClassifier
X, y = make_classification(n_samples=100, n_features=2, random_state=42)
# Overfit model
overfit_knn = KNeighborsClassifier(n_neighbors=1) # n_neighbors=1 → memorizes
overfit_knn.fit(X, y)
# Test robustness: small perturbation
test_point = X[0]
noise = np.random.normal(0, 0.01, test_point.shape) # Small noise
pred_original = overfit_knn.predict([test_point])
pred_noisy = overfit_knn.predict([test_point + noise])
print(f"Original prediction: {pred_original}")
print(f"After tiny noise: {pred_noisy}")
print(f"Sensitive to noise: {pred_original != pred_noisy}")
An overfit model often flips predictions with minimal noise. A robust model is stable.
Robustness techniques:
- Adversarial training: Deliberately add adversarial examples during training.
# Attack: find inputs that fool the model
# Train: add these adversarial examples to training set
Certified robustness: Randomized smoothing guarantees worst-case robustness bounds.
Early stopping + ensemble: Multiple models trained with early stopping on different random seeds increase robustness.
A model that fits the data perfectly but fails on noisy real-world inputs is fundamentally overfitting to exact training patterns, not learned principles.
Source: Goodfellow, Shlens, & Szegedy (2014) — Explaining and Harnessing Adversarial Examples, arXiv preprint.
Real Example: Solving Overfitting
Starting model:
Train accuracy: 99%
Test accuracy: 72%
Gap: 27% → severe overfitting
Step 1: Add cross-validation
CV mean: 0.71, std: 0.08 → unstable
Step 2: Reduce model complexity (max_depth from 20 to 6)
Train: 89%, Test: 85% → much better
Step 3: Add regularization (alpha=0.1)
Train: 87%, Test: 86% → minimal gap, stable
Step 4: Validate on held-out test set
Final test accuracy: 86% → ship it
Frequently Asked Questions
Q: Does a small train-test gap always mean I'm not overfitting?
A: No. If both train and test accuracy are low, you're likely underfitting instead. Always check absolute performance, not just the gap. A small gap with both scores at 60% accuracy is underfitting; a small gap with both at 95% is success.
Q: How big a performance gap is considered "too big"?
A: Generally, any gap greater than 5-10 percentage points warrants investigation. In high-stakes applications like healthcare, even 2-3% gaps matter. The threshold depends on your problem's risk tolerance and how much test data you trust.
Q: Can I overfit to the validation set itself?
A: Yes—if you repeatedly tune hyperparameters based on validation performance, your model learns validation-set quirks rather than generalizable patterns. This is why nested cross-validation and a final held-out test set are essential safeguards.
Q: Why does collecting more training data fix overfitting?
A: More data increases the likelihood that statistical patterns the model learns are real rather than noise. With infinite data, even highly complex models generalize well because overfitting requires unusual data idiosyncrasies that larger datasets average out.
Q: Is overfitting always harmful?
A: In production systems, overfitting always hurts. In research contexts—like fitting benchmark datasets or exploring mathematical properties—controlled overfitting is sometimes intentional. The key distinction is deployment context: research tolerates overfitting to known datasets; real-world systems require generalization.
Q: What's the difference between Ridge, Lasso, and Elastic Net regularization?
A: Ridge (L2) shrinks all weights toward zero but rarely eliminates any completely, good when all features likely matter. Lasso (L1) shrinks some weights to exactly zero, performing automatic feature selection, useful when many features are irrelevant. Elastic Net combines both, balancing feature selection and weight shrinkage.
The takeaway
Overfitting and underfitting are the two failure modes that plague most machine learning projects. Understanding the bias-variance tradeoff gives you the conceptual framework; learning curves and cross-validation give you the diagnostic tools; regularization, ensemble methods, and data augmentation give you the solutions. The discipline of always evaluating on held-out data prevents you from fooling yourself about generalization. Recent discoveries like double descent challenge oversimplified views of model complexity—modern deep learning often benefits from overparameterization when paired with proper training procedures. The practical playbook is: measure the gap between train and test performance, diagnose whether it's bias or variance, apply targeted fixes, validate with nested cross-validation, and deploy only when test performance matches expectations on truly new data.
Read next:
Follow new articles
Add SciHub101 to your feed reader to see new science and technology stories.
About the Author
SciHub101 is an independent science and technology publication founded and edited by Buğra Sözeri, a software engineer and app developer based in Türkiye. AI tools assist with drafting and research. Source documentation in the existing archive is uneven, and articles are being reviewed and corrected. The Editorial Team byline identifies editorial responsibility; it does not certify specialist or independent human review. Where we compare products, anything built by our own team is labelled as such — see the editorial guidelines and About page for how corrections and disclosures work.
Related Articles

RLHF Explained 2026 — How LLMs Learn From Human Feedback
RLHF (reinforcement learning from human feedback) explained simply — how ChatGPT and Claude learn from humans, the limitations, and what replaced it in 2026.

Complete Machine Learning Guide 2026: Concepts to Practice
A practical machine learning hub: the ML workflow, supervised and unsupervised learning, how models learn, metrics, common mistakes, tools, and a learning path.

Why 99% Accuracy Can Be Useless: Precision and Recall
Why 99% accuracy can be useless: how the confusion matrix, precision, recall, F1, and PR curves behave on imbalanced data, with a fully worked example.

