Skip to main content
Machine Learning & Deep Learning

Regularization: Fighting Overfitting

Learn L1 and L2 regularization, dropout, and early stopping to prevent overfitting and improve generalization.

Intermediate26 minBy ToolDix Editorial

Learning objectives

  • Understand L1 and L2 regularization mathematically and apply them using scikit-learn Ridge and Lasso regressions.
  • Implement dropout in PyTorch neural networks and understand why it reduces overfitting via ensemble effects.
  • Use validation curves to select the right regularization strength and implement early stopping to prevent unnecessary computation.

ToolDix original visual

ML & Deep Learning practice loop
1

Frame

Name the outcome and constraints.

2

Build

Try one bounded workflow.

3

Review

Keep evidence, revise, and share.

The Overfitting Crisis

Your model performs beautifully on training data but falters on unseen test data. This is overfitting: the model has memorized noise in the training set rather than learning generalizable patterns. The gap between training error and validation error widens as model complexity grows.

ToolDix original diagram
Regularization tightens the gap between training and validation error
Unregularized
Fit: Complex, wiggly fit
Training error: very low
Validation error: high, diverges from training
Regularized (L1/L2)
Fit: Simpler, smoother fit
Training error: slightly higher
Validation error: lower, tracks training error closely
Regularization adds a penalty term to the loss function -- larger weights cost more, encouraging simpler models that generalize better, even at the cost of slightly higher training error.

The diagram shows the classic U-shaped curve: weak regularization (left side) allows training error to drop sharply while validation error stays high. Too much regularization (right side) and the model becomes underfitted—both errors are high. The sweet spot is where validation error is minimized.

Regularization techniques add a penalty to the loss function or modify the learning process to prefer simpler, more generalizable models. Three approaches dominate: L1 and L2 regularization for classical models, dropout for neural networks, and early stopping to halt training before overfitting occurs.

L2 Regularization: Ridge Regression

L2 regularization (also called ridge regression or Tikhonov regularization) adds a penalty proportional to the square of the weights:

L_regularized = MSE + λ * Σ_i(w_i²)
             = (1/n) * Σ_j(ŷ_j - y_j)² + λ * Σ_i(w_i²)

where λ (lambda) controls regularization strength (non-negative). When λ = 0, there is no regularization. As λ increases, the model prefers smaller weights, leading to simpler, smoother predictions.

Why does this work? Large weights mean the model is sensitive to small changes in input features—a sign of overfitting. By penalizing large weights, regularization forces the model to rely on simpler, more robust patterns.

Intuition: Consider a simple example: fitting y = 2.5*x with noise. An unregularized model might find w = 2.5 exactly. A regularized model (λ > 0) accepts slightly worse fit (e.g., w = 2.4) in exchange for smaller magnitude, which generalizes better to new data with slightly different noise.

Here is a practical example:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge, RidgeCV
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import Pipeline

# Generate synthetic data with true underlying pattern + noise
np.random.seed(42)
X = np.linspace(0, 10, 100).reshape(-1, 1)
y = 3 * np.sin(X) + 5 * X + np.random.normal(0, 5, (100, 1))
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Create a high-degree polynomial model (prone to overfitting)
poly_pipeline = Pipeline([
    ('poly', PolynomialFeatures(degree=10)),
    ('ridge', Ridge(alpha=1.0))  # alpha is λ
])

# Train
poly_pipeline.fit(X_train, y_train)

# Evaluate
train_score = poly_pipeline.score(X_train, y_train)
test_score = poly_pipeline.score(X_test, y_test)
print(f"Ridge (lambda=1.0): Train R²={train_score:.3f}, Test R²={test_score:.3f}")

# Use RidgeCV to automatically select the best λ
ridge_cv = Pipeline([
    ('poly', PolynomialFeatures(degree=10)),
    ('ridge_cv', RidgeCV(alphas=np.logspace(-2, 3, 50)))
])
ridge_cv.fit(X_train, y_train)
best_alpha = ridge_cv.named_steps['ridge_cv'].alpha_
print(f"Best lambda found: {best_alpha:.3f}")

train_score_cv = ridge_cv.score(X_train, y_train)
test_score_cv = ridge_cv.score(X_test, y_test)
print(f"Ridge (lambda=best): Train R²={train_score_cv:.3f}, Test R²={test_score_cv:.3f}")

RidgeCV performs cross-validation over a range of λ values and selects the one that minimizes validation error. The gap between train and test R² typically shrinks as λ increases, until a sweet spot is reached.

L1 Regularization: Lasso Regression

L1 regularization (Lasso, Least Absolute Shrinkage and Selection Operator) adds a penalty proportional to the absolute value of weights:

Loss = MSE + λ * Σ(|w_i|)

L1 differs from L2 in one critical way: it can drive weak weights to exactly zero, effectively selecting a subset of features. This is called automatic feature selection.

from sklearn.linear_model import LassoCV

# Lasso with automatic lambda selection via cross-validation
lasso_cv = Pipeline([
    ('poly', PolynomialFeatures(degree=10)),
    ('lasso_cv', LassoCV(alphas=np.logspace(-2, 3, 50), cv=5, max_iter=10000))
])
lasso_cv.fit(X_train, y_train)

best_alpha_lasso = lasso_cv.named_steps['lasso_cv'].alpha_
n_nonzero = np.sum(lasso_cv.named_steps['lasso_cv'].coef_ != 0)
print(f"Lasso: Best lambda={best_alpha_lasso:.3f}, Non-zero weights={n_nonzero}")

train_score_lasso = lasso_cv.score(X_train, y_train)
test_score_lasso = lasso_cv.score(X_test, y_test)
print(f"Lasso: Train R²={train_score_lasso:.3f}, Test R²={test_score_lasso:.3f}")

In this example, many of the 10 polynomial weights will be driven to zero by Lasso, resulting in a sparser model. Ridge, by contrast, shrinks all weights but rarely zeroes them out.

When to use which:

  • Ridge: When you believe most features are useful, just with excess sensitivity.
  • Lasso: When you suspect only a subset of features matter, or you want an inherently interpretable sparse model.

L1 vs. L2: Mathematical Differences and Practical Implications

Mathematical insight: The key difference is in the penalty norm. L2 penalizes Euclidean distance of the weight vector from zero; L1 penalizes Manhattan distance. Geometrically, this causes L1 to favor sparse solutions (weights exactly zero) while L2 favors many small weights.

Consider optimizing a loss with two weights, w1 and w2, with contours of the loss function forming circles (for quadratic loss). The L2 penalty region (ball) has smooth boundaries, so the optimal w often touches the circle at some interior point. The L1 penalty region (diamond) has sharp corners at the axes (w1=0 or w2=0), so the optimal w often touches a corner—exactly zero.

Practical comparison:

| Property | L2 (Ridge) | L1 (Lasso) | Elastic Net (L1+L2) | |----------|-----------|-----------|------| | Penalty formula | λ * Σ(w_i²) | λ * Σ(|w_i|) | λ₁ * Σ(w_i²) + λ₂ * Σ(|w_i|) | | Effect on weights | Shrinks all toward zero | Drives weak weights to exactly zero | Mix: shrink + sparsity | | Feature selection | No (all features retained) | Yes, automatic (exact zeros) | Yes (partial) | | Computational cost | Closed-form solution (O(n³)) | Iterative, LARS algorithm (O(n²)) | Iterative (LARS variant) | | Interpretability | Harder (many small weights) | Easier (fewer features) | Medium | | Multicollinearity handling | Good (averages correlated features) | Poor (picks one, zeros others) | Good (like Ridge) | | Best for | Few features all useful; correlated features | Many features, sparsity desired | Balance of both | | Example use case | Predicting house price (all features matter) | Gene selection (1000s features, few matter) | Text with many rare words |

The Bias-Variance Tradeoff

Regularization works by accepting slightly higher training error to achieve lower test error. This is the classic bias-variance tradeoff:

  • High bias (underfitting): Model is too simple, misses true patterns. Both training and validation errors are high.
  • High variance (overfitting): Model is too complex, fits noise. Training error is low, validation error is high.

Regularization increases bias slightly (the model has less flexibility) to dramatically reduce variance (noise is penalized). The optimal regularization strength balances these two.

# Demonstrate bias-variance with polynomial regression
np.random.seed(42)
X = np.linspace(-2, 2, 50).reshape(-1, 1)
y_true = np.sin(X)
y = y_true + np.random.randn(50, 1) * 0.3  # Add noise

X_test = np.linspace(-2, 2, 100).reshape(-1, 1)
y_test_true = np.sin(X_test)

degrees = [1, 3, 10]
lambdas = [0, 0.01, 1.0]

fig, axes = plt.subplots(3, 3, figsize=(14, 10))

for i, degree in enumerate(degrees):
    for j, lam in enumerate(lambdas):
        # Train polynomial with regularization
        poly = PolynomialFeatures(degree=degree)
        X_poly = poly.fit_transform(X)
        X_test_poly = poly.transform(X_test)

        ridge = Ridge(alpha=lam)
        ridge.fit(X_poly, y)
        y_pred = ridge.predict(X_test_poly)

        # Plot
        ax = axes[i, j]
        ax.scatter(X, y, alpha=0.5, label='Training data')
        ax.plot(X_test, y_test_true, 'g--', linewidth=2, label='True function')
        ax.plot(X_test, y_pred, 'r-', linewidth=2, label='Model fit')
        ax.set_ylim([-2, 2])
        ax.set_title(f'Degree {degree}, λ={lam}')
        if j == 0:
            ax.set_ylabel('y')
        if i == 2:
            ax.set_xlabel('x')

plt.tight_layout()
plt.show()

The plots show: as degree increases (more flexible), variance increases. As λ increases (more regularization), variance decreases but bias increases. The sweet spot is typically in the middle.

Dropout: Regularization for Neural Networks

Dropout is a simple yet powerful technique for neural networks. During training, randomly drop (set to zero) a fraction of neurons in a layer with probability p. At test time, use all neurons but scale their outputs by (1 - p) to maintain expected values.

Why does this work? Dropping neurons forces the network to learn redundant representations. No single neuron can dominate the prediction. This is like training an ensemble of networks with different subsets of neurons and averaging their predictions at test time. The connection to ensembles is deep: with p = 0.5 dropout, you're training 2^n different networks (where n is the number of units) in parallel, each on a different random subset of neurons.

Mathematical insight: Dropout can be viewed as a form of noise injection. A neuron output a with dropout applied becomes:

a_dropped = a * b,  where b ~ Bernoulli(1 - p)

At test time, to account for the reduced activations, we scale by 1/(1-p):

a_test = a * (1 / (1 - p))

This scaling ensures that the expected value of activations during training and testing is the same, avoiding a mismatch.

Here is a PyTorch example:

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

# Create synthetic data
X_train_tensor = torch.randn(200, 20)
y_train_tensor = torch.randint(0, 2, (200,))
X_val_tensor = torch.randn(50, 20)
y_val_tensor = torch.randint(0, 2, (50,))

# Model without dropout (prone to overfitting)
model_no_dropout = nn.Sequential(
    nn.Linear(20, 128),
    nn.ReLU(),
    nn.Linear(128, 64),
    nn.ReLU(),
    nn.Linear(64, 2)
)

# Model with dropout
model_with_dropout = nn.Sequential(
    nn.Linear(20, 128),
    nn.ReLU(),
    nn.Dropout(p=0.5),  # Drop 50% of neurons
    nn.Linear(128, 64),
    nn.ReLU(),
    nn.Dropout(p=0.5),
    nn.Linear(64, 2)
)

# Training function
def train_epoch(model, loader, optimizer, criterion):
    model.train()  # Enable dropout
    total_loss = 0
    for X, y in loader:
        optimizer.zero_grad()
        logits = model(X)
        loss = criterion(logits, y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(loader)

def evaluate(model, X, y, criterion):
    model.eval()  # Disable dropout
    with torch.no_grad():
        logits = model(X)
        loss = criterion(logits, y)
    return loss.item()

# Setup
dataset = TensorDataset(X_train_tensor, y_train_tensor)
loader = DataLoader(dataset, batch_size=32, shuffle=True)
criterion = nn.CrossEntropyLoss()

# Train model with dropout
optimizer = optim.Adam(model_with_dropout.parameters(), lr=0.01)
for epoch in range(50):
    train_loss = train_epoch(model_with_dropout, loader, optimizer, criterion)
    val_loss = evaluate(model_with_dropout, X_val_tensor, y_val_tensor, criterion)
    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch+1}: Train loss={train_loss:.4f}, Val loss={val_loss:.4f}")

The key is calling model.train() during training (dropout is active) and model.eval() during evaluation (dropout is disabled, neurons are scaled). The Dropout(p=0.5) layer drops each neuron independently with probability 0.5.

Dropout parameter guidelines:

  • Typical p for hidden layers: 0.5 (50% dropout).
  • For the input layer: 0.1–0.2 (less aggressive).
  • Avoid dropout in the output layer—you want the final prediction to use all information.

Early Stopping: Halt Before Overfitting

Even with regularization, training a neural network long enough will eventually overfit. Early stopping monitors validation loss and halts training when it stops improving.

class EarlyStopping:
    """Stop training when validation loss stops improving."""
    def __init__(self, patience=5, delta=0.0):
        self.patience = patience
        self.delta = delta
        self.best_loss = None
        self.patience_counter = 0

    def __call__(self, val_loss):
        if self.best_loss is None:
            self.best_loss = val_loss
        elif val_loss < self.best_loss - self.delta:
            self.best_loss = val_loss
            self.patience_counter = 0
        else:
            self.patience_counter += 1
        return self.patience_counter >= self.patience  # Return True to stop

# Training loop with early stopping
early_stop = EarlyStopping(patience=10, delta=1e-4)
model = model_with_dropout
optimizer = optim.Adam(model.parameters(), lr=0.01)

for epoch in range(500):
    train_loss = train_epoch(model, loader, optimizer, criterion)
    val_loss = evaluate(model, X_val_tensor, y_val_tensor, criterion)

    if early_stop(val_loss):
        print(f"Early stopping at epoch {epoch+1}")
        break

    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1}: Train loss={train_loss:.4f}, Val loss={val_loss:.4f}")

The EarlyStopping checker tracks the best validation loss seen so far. If validation loss does not improve for patience consecutive epochs, training stops. This saves compute and prevents unnecessary overfitting.

Comprehensive regularization comparison across all techniques:

| Technique | Type | Hyperparameters | When Training Loss > Val Loss | When Training Loss ≈ Val Loss | |-----------|------|--------|------|------| | L2 (Ridge) | Weight penalty | λ (strength) | Decrease λ | Increase λ | | L1 (Lasso) | Weight penalty | λ (strength) | Decrease λ | Increase λ | | Elastic Net | Weight penalty | λ, α (L1 ratio) | Decrease λ | Increase λ | | Dropout | Unit drop | p (drop rate) | Decrease p | Increase p | | Early Stopping | Iteration limit | patience (epochs) | Increase patience | Decrease patience |

Real-world decision tree: Does your training loss keep dropping while validation plateaus? Increase regularization (λ or p) or enable early stopping. Does both losses plateau immediately? Your model is underfitting; try reducing regularization, increasing model capacity, or engineering better features.

Validation Curves: Visualizing Regularization Strength

To choose the right regularization strength, plot training and validation error against the regularization parameter (λ for Ridge/Lasso, dropout rate for neural networks, patience for early stopping).

from sklearn.model_selection import validation_curve

# Compute training and validation curves for Ridge regularization
alphas = np.logspace(-3, 3, 20)
train_scores, val_scores = validation_curve(
    estimator=Pipeline([
        ('poly', PolynomialFeatures(degree=10)),
        ('ridge', Ridge())
    ]),
    X=X_train,
    y=y_train,
    param_name='ridge__alpha',
    param_range=alphas,
    cv=5,
    scoring='r2'
)

# Plot
plt.figure(figsize=(8, 5))
plt.plot(alphas, train_scores.mean(axis=1), 'o-', label='Train R²', color='blue')
plt.plot(alphas, val_scores.mean(axis=1), 's-', label='Validation R²', color='red')
plt.xscale('log')
plt.xlabel('Regularization strength (alpha)')
plt.ylabel('R² Score')
plt.legend()
plt.title('Validation Curve: Ridge Regularization')
plt.grid(True, alpha=0.3)
plt.show()

This plots R² on both training and validation sets across a range of lambda values. The optimal lambda is where validation R² is highest. If training R² is much higher than validation R², the model is overfitting; increase lambda.


Elastic Net: Combining L1 and L2

Sometimes, neither pure L1 nor pure L2 is ideal. Elastic Net combines both:

Loss = MSE + λ₁ * Σ(w_i²) + λ₂ * Σ(|w_i|)

This inherits benefits from both: L2 prevents extreme weights, and L1 promotes sparsity. A mixing parameter α ∈ [0, 1] controls the balance:

from sklearn.linear_model import ElasticNetCV

elastic_net = Pipeline([
    ('poly', PolynomialFeatures(degree=10)),
    ('elastic_net', ElasticNetCV(l1_ratio=0.5, alphas=np.logspace(-2, 3, 50), cv=5))
])
elastic_net.fit(X_train, y_train)
print(f"Elastic Net alpha: {elastic_net.named_steps['elastic_net'].alpha_}")

Elastic Net is useful when you have many features and suspect some are irrelevant (want L1 sparsity) but also want to stabilize predictions (L2 penalty).

Elastic Net hyperparameter guide:

| l1_ratio (α) | L1 Contribution | L2 Contribution | When to Use | Typical λ Range | |------|---|---|---|---| | 0.0 | 0% | 100% (pure L2/Ridge) | Many correlated features | 0.001 - 100 | | 0.2 | 20% | 80% | Mostly L2 benefit, some sparsity | 0.001 - 100 | | 0.5 | 50% | 50% (balance) | Balanced; unsure which to pick | 0.001 - 100 | | 0.8 | 80% | 20% | Mostly L1 benefit, some stabilization | 0.001 - 1 | | 1.0 | 100% | 0% (pure L1/Lasso) | High-dimensional, sparse solution desired | 0.001 - 1 |

Start with α=0.5 (balanced) and tune λ via cross-validation. If many features are irrelevant, increase α. If features are correlated, decrease α.

Regularization in Practice: A Real Dataset Example

Let's apply regularization to a realistic scenario—predicting house prices with many features:

from sklearn.datasets import fetch_openml
from sklearn.model_selection import cross_validate
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

# Simulate a regression dataset with 100 features, many irrelevant
np.random.seed(42)
n_samples = 500
n_features = 100
X = np.random.randn(n_samples, n_features)
# True relationship uses only 10 features
true_coef = np.zeros(n_features)
true_coef[:10] = np.random.randn(10) * 10
y = X @ true_coef + np.random.randn(n_samples) * 2

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

# Compare Ridge, Lasso, and no regularization
models = {
    'No Regularization': Pipeline([
        ('scaler', StandardScaler()),
        ('model', Ridge(alpha=0))  # alpha=0 is no regularization
    ]),
    'Ridge (L2)': Pipeline([
        ('scaler', StandardScaler()),
        ('model', Ridge(alpha=1.0))
    ]),
    'Lasso (L1)': Pipeline([
        ('scaler', StandardScaler()),
        ('model', Lasso(alpha=0.1, max_iter=10000))
    ])
}

for name, model in models.items():
    model.fit(X_train, y_train)
    train_r2 = model.score(X_train, y_train)
    test_r2 = model.score(X_test, y_test)
    n_nonzero = np.sum(model.named_steps['model'].coef_ != 0)
    print(f"{name}:")
    print(f"  Train R² = {train_r2:.4f}, Test R² = {test_r2:.4f}")
    print(f"  Non-zero weights = {n_nonzero}")

Expected results:

  • No regularization: High train R², low test R² (overfitting).
  • Ridge: Moderate train R², high test R², all weights retained.
  • Lasso: Moderate train R², high test R², sparse (many weights = 0).

Ridge is safer for general use, while Lasso excels when you want interpretability or suspect many features are irrelevant.


Common Mistake: Ignoring the Regularization Strength

A common mistake is enabling regularization (L1, L2, or dropout) but leaving the strength at a default value. Default parameters are rarely optimal for your specific problem.

Always validate:

  1. Create a validation curve to visualize the trade-off.
  2. Use cross-validation (e.g., RidgeCV, LassoCV) to automatically select the best strength.
  3. Monitor both training and validation metrics during neural network training; if validation error plateaus, you may need stronger regularization.

Regularization is not a one-time setting. As you add more features, increase model capacity, or collect more data, you may need to adjust regularization strength to maintain the optimal bias-variance trade-off.

Why Regularization Fails

Regularization is not a magic bullet. It can hurt performance if misapplied:

  1. Too much regularization: Your model becomes too simple and underfits, even on the training set.
  2. Wrong type: Using L1 when L2 is better (or vice versa) for your problem.
  3. Not scaled properly: If features have different scales, regularization affects them unequally. Always scale features before applying L1 or L2.
  4. Disabled for test evaluation: Ensure you use the same regularization strength for both training and testing. Changing it between is a silent form of leakage.

A telltale sign: if both training and validation errors are high, the model is underfitting. Reduce regularization. If training error is low but validation high, overfitting remains. Increase regularization.

Sources and license context

These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.

Keep going

Read these next on ToolDix.

Original lessons that build on what you just read.