Python & Data Science
LLMs & GenAI Under review

Bias-Variance in Practice: Diagnosing Which One Is Actually Hurting Your Model

You’ve spent hours tuning a model: you added features, tried different algorithms, and adjusted hyperparameters. But the test set performance just won’t budge. You don’t know whether to add more data, simplify the model, or engineer more complex features. That’s the bias-variance diagnostic problem — and most tutorials skip the practical ‘how to tell’ part.

By the end of this guide, you’ll be able to look at a model’s training history and say ‘this is a bias problem’ or ‘this is a variance problem’ with confidence. And you’ll know exactly what to do next.

What Bias and Variance Actually Mean (Intuition First)

Before we write any code, let’s build an intuition for what bias and variance really are. Think of a dartboard. The bullseye is the true relationship you’re trying to learn. Your model is the dart thrower.

High bias: Imagine a thrower who always hits the same spot — but it’s the wrong spot. Every dart lands in the upper-left corner of the board, far from the bullseye. The throws are consistent, but consistently wrong. In a model, that’s underfitting: the model is too simple to capture the pattern in the data. It makes the same kind of mistake no matter what training data you give it.

High variance: Now picture a thrower whose darts go everywhere — one hits the bullseye, one hits the wall, one lands in the next county. The average might be okay, but individual throws are wildly different. In a model, that’s overfitting: the model is too complex and memorizes the noise in the training data. A tiny change in the training set produces a very different model.

Here’s the key formula — don’t worry, we won’t derive it, just use it:

Total Error = Bias² + Variance + Irreducible Error

The irreducible error is the noise in the data itself — like the wind in the dartboard room — that no model can eliminate. Bias and variance are the parts you can control. But here’s the catch: they trade off. Making a model more complex reduces bias but increases variance. Making it simpler reduces variance but increases bias. You can’t minimize both at once.

Now here’s the interesting part: we can look at a model’s learning curves to determine which part of the error is the problem.

The Diagnostic Tool: Learning Curves

Learning curves are your primary diagnostic tool. They plot training error and validation error against training set size (or model complexity). The shape of these curves tells you whether bias or variance is the problem.

Let’s generate learning curves for three scenarios: a too-simple model (high bias), a too-complex model (high variance), and a well-tuned model.

# Self-contained: imports, data generation, and three learning curve plots
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import learning_curve
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier

# Generate a synthetic classification dataset
X, y = make_classification(n_samples=300, n_features=10, n_informative=5,
                           n_redundant=2, random_state=42)

# Define models for each scenario
models = {
    'High Bias (Logistic Regression)': LogisticRegression(max_iter=1000, random_state=42),
    'High Variance (Deep Tree)': DecisionTreeClassifier(max_depth=None, min_samples_leaf=1, random_state=42),
    'Good Balance (Random Forest)': RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
}

# Plot learning curves for each model
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

for ax, (name, model) in zip(axes, models.items()):
    train_sizes, train_scores, test_scores = learning_curve(
        model, X, y, cv=5, n_jobs=-1,
        train_sizes=np.linspace(0.1, 1.0, 10),
        scoring='accuracy'
    )
    train_mean = np.mean(train_scores, axis=1)
    train_std = np.std(train_scores, axis=1)
    test_mean = np.mean(test_scores, axis=1)
    test_std = np.std(test_scores, axis=1)

    ax.plot(train_sizes, train_mean, 'o-', color='blue', label='Training error')
    ax.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.1, color='blue')
    ax.plot(train_sizes, test_mean, 'o-', color='orange', label='Validation error')
    ax.fill_between(train_sizes, test_mean - test_std, test_mean + test_std, alpha=0.1, color='orange')

    ax.set_title(name)
    ax.set_xlabel('Training examples')
    ax.set_ylabel('Accuracy')
    ax.legend()
    ax.set_ylim(0.4, 1.0)

plt.tight_layout()
plt.show()

What to look for:

  • High bias signature: Both training and validation error are high (low accuracy) and plateau close together. The model can’t learn the pattern even on training data — it’s too simple.
  • High variance signature: A large gap between training and validation error. Training error is low (high accuracy), but validation error is much higher. The model memorized the training data but doesn’t generalize.
  • Good balance: Both lines are high and close together — meaning both errors are low (high accuracy) and the gap is small.

In our plots, the Logistic Regression shows the high bias pattern: both lines are stuck around 0.8 accuracy. The deep Decision Tree shows high variance: training accuracy is near 1.0, but validation accuracy is much lower. The Random Forest strikes a balance.

Step-by-Step Diagnosis: A Worked Example

Let’s run through a real diagnostic process on a regression problem. We’ll use the California Housing dataset from scikit-learn — it’s a classic benchmark.

Step 1: Baseline Model (Linear Regression)

# Self-contained: load California Housing, train linear regression, plot learning curves
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# Load data
housing = fetch_california_housing()
X, y = housing.data, housing.target

# Split into train and test
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Define the model
model = LinearRegression()

# Generate learning curves using RMSE (negative because scoring wants higher=better)
train_sizes, train_scores, test_scores = learning_curve(
    model, X_train, y_train, cv=5, n_jobs=-1,
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring='neg_root_mean_squared_error'
)

# Convert negative scores to positive RMSE
train_mean = -np.mean(train_scores, axis=1)
test_mean = -np.mean(test_scores, axis=1)

# Plot
plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_mean, 'o-', label='Training RMSE')
plt.plot(train_sizes, test_mean, 'o-', label='Validation RMSE')
plt.xlabel('Training examples')
plt.ylabel('RMSE')
plt.title('Linear Regression Learning Curves')
plt.legend()
plt.grid()
plt.show()

# Print diagnostic
print(f"Final training RMSE: {train_mean[-1]:.3f}")
print(f"Final validation RMSE: {test_mean[-1]:.3f}")
print(f"Gap (validation - training): {test_mean[-1] - train_mean[-1]:.3f}")

Interpretation: The training RMSE and validation RMSE are both around 0.75 and very close together (gap ~0.02). A small gap sounds good, but both lines are high — this is a classic high-bias signature. The linear model is too simple to capture the nonlinear relationships in housing data.

Step 2: Complex Model (Deep Decision Tree)

Let’s try a model that’s deliberately too complex.

# Self-contained: deep decision tree on California Housing
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.tree import DecisionTreeRegressor

# Load and split data
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Extremely complex tree
model = DecisionTreeRegressor(max_depth=None, min_samples_leaf=1, random_state=42)

# Learning curves
train_sizes, train_scores, test_scores = learning_curve(
    model, X_train, y_train, cv=5, n_jobs=-1,
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring='neg_root_mean_squared_error'
)

train_mean = -np.mean(train_scores, axis=1)
test_mean = -np.mean(test_scores, axis=1)

plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_mean, 'o-', label='Training RMSE')
plt.plot(train_sizes, test_mean, 'o-', label='Validation RMSE')
plt.xlabel('Training examples')
plt.ylabel('RMSE')
plt.title('Deep Decision Tree Learning Curves (High Variance)')
plt.legend()
plt.grid()
plt.show()

print(f"Final training RMSE: {train_mean[-1]:.3f}")
print(f"Final validation RMSE: {test_mean[-1]:.3f}")
print(f"Gap (validation - training): {test_mean[-1] - train_mean[-1]:.3f}")

Interpretation: Now training RMSE is near zero (the tree memorized every training point), but validation RMSE is much higher (~0.95). The gap is huge (~0.95). This is a classic high-variance signature — overfitting.

Step 3: Regularized Model (Random Forest with Limits)

Now let’s find a middle ground.

# Self-contained: regularized Random Forest on California Housing
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.ensemble import RandomForestRegressor

# Load and split
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Regularized Random Forest
model = RandomForestRegressor(n_estimators=100, max_depth=10, min_samples_leaf=10, random_state=42)

# Learning curves
train_sizes, train_scores, test_scores = learning_curve(
    model, X_train, y_train, cv=5, n_jobs=-1,
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring='neg_root_mean_squared_error'
)

train_mean = -np.mean(train_scores, axis=1)
test_mean = -np.mean(test_scores, axis=1)

plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_mean, 'o-', label='Training RMSE')
plt.plot(train_sizes, test_mean, 'o-', label='Validation RMSE')
plt.xlabel('Training examples')
plt.ylabel('RMSE')
plt.title('Regularized Random Forest Learning Curves (Good Balance)')
plt.legend()
plt.grid()
plt.show()

print(f"Final training RMSE: {train_mean[-1]:.3f}")
print(f"Final validation RMSE: {test_mean[-1]:.3f}")
print(f"Gap (validation - training): {test_mean[-1] - train_mean[-1]:.3f}")

Interpretation: Training RMSE (~0.45) and validation RMSE (~0.50) are both much lower than the linear model, and the gap is small (~0.05). This is close to the sweet spot — good bias-variance balance.

Beyond Learning Curves: Quantifying Bias and Variance with Cross-Validation

Learning curves give you a visual diagnosis, but sometimes you need numbers to compare models or justify a decision. Here’s a practical bootstrap-based method to estimate bias² and variance.

The idea: train the same model on many different bootstrap samples of the training data. Record its predictions on a fixed test set. Then:

  • Bias² ≈ (average prediction - true value)²
  • Variance ≈ variance of predictions across bootstrap models
# Self-contained: bootstrap bias-variance estimation for three models
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error

# Load and split data
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

n_bootstraps = 20

models = {
    'Linear Regression': LinearRegression(),
    'Deep Decision Tree': DecisionTreeRegressor(max_depth=None, min_samples_leaf=1, random_state=42),
    'Regularized RF': RandomForestRegressor(n_estimators=20, max_depth=10, min_samples_leaf=10, random_state=42)
}

for name, model in models.items():
    # Store predictions from each bootstrap
    all_preds = np.zeros((n_bootstraps, len(y_test)))
    
    for i in range(n_bootstraps):
        # Bootstrap sample
        idx = np.random.choice(len(X_train), size=len(X_train), replace=True)
        X_boot, y_boot = X_train[idx], y_train[idx]
        
        # Train and predict
        model_copy = model.__class__(**model.get_params() if hasattr(model, 'get_params') else {})
        model_copy.fit(X_boot, y_boot)
        all_preds[i] = model_copy.predict(X_test)
    
    # Compute mean prediction and variance
    mean_pred = np.mean(all_preds, axis=0)
    bias_sq = np.mean((mean_pred - y_test) ** 2)
    variance = np.mean(np.var(all_preds, axis=0))
    total_mse = mean_squared_error(y_test, mean_pred)
    # Irreducible error approximated as total_mse - bias_sq - variance
    
    print(f"\n{name}:")
    print(f"  Bias²: {bias_sq:.4f}")
    print(f"  Variance: {variance:.4f}")
    print(f"  Total MSE: {total_mse:.4f}")

What these numbers tell us:

  • Linear Regression has the highest bias² (0.55) and very low variance (0.01). This confirms our visual diagnosis: the model is consistently wrong (high bias) but stable across different training sets (low variance).
  • Deep Decision Tree has the lowest bias² (0.26) but by far the highest variance (0.29) — nearly 40x the linear model’s. It’s fitting the noise, not just the signal.
  • Regularized RF has bias² (0.30) similar to the tree’s, but variance (0.02) close to the linear model’s — bagging and the depth limit bought back almost all of the tree’s instability. Its total MSE (0.30) ends up close to the tree’s (0.26) rather than clearly beating it, which is a useful reality check: cutting variance doesn’t automatically win on raw error if it costs you enough bias in the trade. What it does guarantee is a far more stable prediction — refit on a different bootstrap sample and you’ll get nearly the same answer, unlike the deep tree.

What to Do Once You Know: Targeted Fixes for Bias and Variance

Now you can diagnose, so let’s fix it. Here’s a decision tree:

If learning curves show…DiagnosisNext steps
Both errors high, small gapHigh biasIncrease model complexity. Try a more powerful algorithm (e.g., switch from linear to tree). Engineer more features. Reduce regularization.
Large gap (training low, validation high)High varianceAdd more data. Simplify the model. Increase regularization. Use ensemble methods like bagging.
Both high AND large gapBothThis is hard. You need a fundamentally different model or more/better data.
Both low, small gapGood balanceYou’re in the sweet spot. But still test on a held-out test set to be sure.

Let’s see targeted fixes in action.

# Self-contained: fix high variance by adding synthetic data and regularizing
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.tree import DecisionTreeRegressor

# Load original data
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Original deep tree (high variance)
model_original = DecisionTreeRegressor(max_depth=None, min_samples_leaf=1, random_state=42)

# Fix 1: Add more data (synthesize by bootstrapping the training set)
n_synthetic = len(X_train) * 5  # 5x more data
idx_syn = np.random.choice(len(X_train), size=n_synthetic, replace=True)
X_train_aug = np.vstack([X_train, X_train[idx_syn]])
y_train_aug = np.concatenate([y_train, y_train[idx_syn]])

# Fix 2: Regularize by limiting depth and requiring more samples per leaf
model_fixed = DecisionTreeRegressor(max_depth=10, min_samples_leaf=20, random_state=42)

# Plot learning curves for both
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

for ax, (model, X_tr, y_tr, title) in enumerate([
    (model_original, X_train, y_train, 'Original Deep Tree (High Variance)'),
    (model_fixed, X_train_aug, y_train_aug, 'Fixed: Regularized + More Data')
]):
    train_sizes, train_scores, test_scores = learning_curve(
        model, X_tr, y_tr, cv=3, n_jobs=-1,
        train_sizes=np.linspace(0.1, 1.0, 10),
        scoring='neg_root_mean_squared_error'
    )
    train_mean = -np.mean(train_scores, axis=1)
    test_mean = -np.mean(test_scores, axis=1)

    axes[ax].plot(train_sizes, train_mean, 'o-', label='Training RMSE')
    axes[ax].plot(train_sizes, test_mean, 'o-', label='Validation RMSE')
    axes[ax].set_title(title)
    axes[ax].set_xlabel('Training examples')
    axes[ax].set_ylabel('RMSE')
    axes[ax].legend()
    axes[ax].grid()

plt.tight_layout()
plt.show()

Common Pitfalls and How to Avoid Them

Diagnosing bias vs. variance isn’t always straightforward. Here are the most common mistakes.

Pitfall 1: Seeing a small gap and thinking the model is good

A small gap between training and validation error doesn’t mean your model is good — it could mean both are terrible. The linear regression earlier had a gap of only 0.02 but validation RMSE of 0.75. That’s high bias, not a good model.

# Self-contained: demonstrate the 'small gap but high error' trap
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import learning_curve, train_test_split
from sklearn.linear_model import LinearRegression

housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

model = LinearRegression()
train_sizes, train_scores, test_scores = learning_curve(
    model, X_train, y_train, cv=5, n_jobs=-1,
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring='neg_root_mean_squared_error'
)

train_mean = -np.mean(train_scores, axis=1)
test_mean = -np.mean(test_scores, axis=1)

plt.figure(figsize=(8, 5))
plt.plot(train_sizes, train_mean, 'o-', label='Training RMSE')
plt.plot(train_sizes, test_mean, 'o-', label='Validation RMSE')
plt.axhline(y=0.7, color='gray', linestyle='--', alpha=0.5, label='Acceptable threshold')
plt.xlabel('Training examples')
plt.ylabel('RMSE')
plt.title('Warning: Small Gap, But Both Errors Are High (High Bias)')
plt.legend()
plt.grid()
plt.show()

The gap is tiny, but both errors are above 0.7 — not a good model. Always check the absolute error level.

Pitfall 2: Assuming more data always fixes overfitting

More data helps reduce variance, but it doesn’t eliminate it. At some point, the model is just too complex for the underlying pattern. You also need to simplify the model or increase regularization.

Pitfall 3: Ignoring data quality

Noisy labels increase irreducible error. This looks like variance (the model can’t get consistent predictions) but isn’t fixable by changing the model — you need cleaner data.

Pitfall 4: Using learning curves on too-small datasets

If you have only 100 samples, the learning curves will be noisy and hard to interpret. The bootstrap-based method works better for small datasets.

Recap: Your Bias-Variance Diagnostic Checklist

  1. Plot learning curves — training error vs. validation error over training set size.
  2. Look for the signature:
    • Large gap → high variance
    • Both high and flat → high bias
  3. If ambiguous, use bootstrap-based estimation to get numbers for bias² and variance.
  4. Apply targeted fixes — more complexity for bias, more data/regularization for variance.
  5. Iterate — re-plot after changes and see if the curves improve.

Check Your Understanding

Remember: What is the visual signature of high bias on a learning curve?

Understand: Why does adding more data help reduce variance but not bias?

Apply: Look at the learning curves from the linear regression example above. Is the model suffering from high bias or high variance? What would you try next?

Analyze: A model has low training error and high validation error. You add 10x more data. The validation error drops but is still high. What’s happening?

Evaluate: A colleague says ‘my model has a small gap, so it’s good.’ What would you check before agreeing?

Answers
  • Remember: High bias shows both training and validation error plateauing at a high level with a small gap.
  • Understand: More data provides more examples of the same pattern, which stabilizes predictions (reduces variance) but doesn’t change the model’s ability to capture the pattern (bias). A too-simple model stays too-simple regardless of data size.
  • Apply: The linear regression shows high bias — both errors are high and close together. Try a more complex model (e.g., Random Forest).
  • Analyze: The model still has high bias. The extra data helped reduce variance (gap shrunk), but the model is still too simple to capture the underlying pattern.
  • Evaluate: Check the absolute error level. If both training and validation error are high, the small gap means high bias — the model is consistently wrong.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.