Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced

Training Deep Networks — Adam, BatchNorm, Dropout

The four techniques that separate a network that trains from one that trains well. Used in every production deep learning system.

22–28 min March 2026
Before any code — what problem does this module solve?

Module 41 built a network that works. This module makes it train 10× faster, generalise better, and stay stable on deep architectures.

The network from Module 41 used plain SGD — subtract a fixed fraction of the gradient from each weight every step. It works, but it has three serious problems in practice. First: the same learning rate for every weight regardless of how frequently that weight gets useful gradient signal. Second: as the network gets deeper, activations between layers drift to extreme values — making gradients vanish or explode. Third: the network memorises training data instead of learning generalisable patterns.

Four techniques solve these problems and together define how every modern neural network is trained in production: Adam (adaptive learning rates), Batch Normalisation (stabilise activations), Dropout (prevent overfitting), and Learning Rate Scheduling(decay lr as training matures). You will use all four in every non-trivial deep learning project you build.

🧠 Analogy — read this first

Learning to drive with plain SGD is like driving with one fixed foot pressure on the accelerator — too fast on empty roads, too slow in traffic. Adam is an experienced driver who automatically adjusts pressure based on the terrain — easing off on straight highways, pressing harder on uphill roads.

BatchNorm is the suspension system — absorbs shocks between layers so the car stays stable. Dropout is the practice of occasionally driving with one eye closed — forces the driver to not rely too heavily on any single cue, producing a more robust skill. LR scheduling is easing off the accelerator as you approach your destination.

🎯 Pro Tip
These four techniques are not optional polish — they are the difference between a network that trains in 200 epochs and one that trains in 20, and between a network that overfits immediately and one that generalises. Every PyTorch tutorial uses them by default for good reason.
Technique 1 — adaptive learning rates

Adam — the optimizer that replaced SGD for almost everything

Plain SGD uses the same learning rate for every weight at every step. This is suboptimal for two reasons. Some weights receive large, consistent gradient signals and should take smaller steps to avoid overshooting. Other weights receive rare, small gradient signals and should take larger steps to make progress at all. Adam maintains a separate effective learning rate for each weight, automatically adjusted based on the history of gradients for that weight.

Adam combines two ideas. The first moment (mean of past gradients) acts like momentum — it accumulates direction from past steps, smoothing out noise. The second moment (mean of squared past gradients) scales the step size — weights that have been receiving large gradients get a smaller effective learning rate. Together they produce an adaptive per-weight step size that makes training much faster and more robust to learning rate choice.

Adam — the four update equations
m = β₁ × m + (1−β₁) × g ← first moment (momentum)
v = β₂ × v + (1−β₂) × g² ← second moment (gradient variance)
m̂ = m / (1−β₁ᵗ) ← bias correction
v̂ = v / (1−β₂ᵗ)
W = W − lr × m̂ / (√v̂ + ε) ← weight update
g: gradient for this step
β₁=0.9 (default): momentum decay — how much to remember past gradients
β₂=0.999 (default): variance decay — how much to remember past gradient²
ε=1e-8 (default): prevents division by zero
Effective lr per weight ≈ lr / √(mean gradient²) — large past gradients → small steps
python
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
torch.manual_seed(42)
n = 3000

distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
value    = np.abs(np.random.normal(350, 150, n)).clip(50, 1200)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep, value])
y = delivery.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

X_mean, X_std = X_tr.mean(0), X_tr.std(0) + 1e-8
y_mean, y_std = y_tr.mean(), y_tr.std() + 1e-8
X_tr_sc = torch.FloatTensor((X_tr - X_mean) / X_std)
y_tr_sc = torch.FloatTensor((y_tr - y_mean) / y_std)
X_te_sc = torch.FloatTensor((X_te - X_mean) / X_std)

def make_model():
    return nn.Sequential(
        nn.Linear(4, 64), nn.ReLU(),
        nn.Linear(64, 32), nn.ReLU(),
        nn.Linear(32, 1),
    )

def train_model(model, optimizer, epochs=100, batch_size=64):
    criterion = nn.MSELoss()
    dataset   = torch.utils.data.TensorDataset(X_tr_sc, y_tr_sc)
    loader    = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)
    losses    = []
    for epoch in range(epochs):
        model.train()
        for Xb, yb in loader:
            optimizer.zero_grad()
            loss = criterion(model(Xb), yb)
            loss.backward()
            optimizer.step()
        model.eval()
        with torch.no_grad():
            losses.append(criterion(model(X_tr_sc), y_tr_sc).item())
    return losses

# ── Compare SGD vs Adam vs AdamW ──────────────────────────────────────
optimizers = {
    'SGD (lr=0.01)':          lambda m: optim.SGD(m.parameters(), lr=0.01),
    'SGD+momentum (lr=0.01)': lambda m: optim.SGD(m.parameters(), lr=0.01, momentum=0.9),
    'Adam (lr=0.001)':        lambda m: optim.Adam(m.parameters(), lr=0.001),
    'AdamW (lr=0.001)':       lambda m: optim.AdamW(m.parameters(), lr=0.001, weight_decay=0.01),
}

print(f"{'Optimizer':<28} {'Final loss':>12} {'Test MAE':>10}")
print("─" * 54)

for name, opt_fn in optimizers.items():
    torch.manual_seed(42)
    model  = make_model()
    losses = train_model(model, opt_fn(model))

    model.eval()
    with torch.no_grad():
        y_pred = model(X_te_sc).numpy() * y_std + y_mean
    mae = mean_absolute_error(y_te, y_pred)
    print(f"  {name:<26}  {losses[-1]:>12.6f}  {mae:>10.4f}")

# ── AdamW vs Adam: weight decay ────────────────────────────────────────
print("
AdamW adds L2 weight decay directly to the weight update,")
print("not to the gradient — more correct than Adam + weight_decay.")
print("Use AdamW as default for all transformer/MLP training.")
Technique 2 — stabilise activations between layers

Batch Normalisation — the technique that made very deep networks trainable

As data flows through a deep network, the distribution of activations at each layer shifts and grows — a problem called internal covariate shift. Layer 3 expects activations in a certain range, but layer 2's outputs drift as weights update. Layer 3 then has to continuously re-adapt to its changing inputs. This is one reason very deep networks (10+ layers) trained poorly before BatchNorm.

Batch Normalisation fixes this by normalising the activations at each layer before passing them to the next. For each mini-batch, it subtracts the batch mean and divides by the batch standard deviation — forcing activations to approximately zero mean and unit variance. Two learnable parameters (gamma and beta) then scale and shift the normalised values back to whatever distribution is optimal for that layer.

BatchNorm — what it does to activations
WITHOUT BatchNorm — activations drift
Layer 1Layer 2Layer 3Layer 4activations grow and shift
WITH BatchNorm — activations stay stable
Layer 1Layer 2Layer 3Layer 4consistent distribution per layer
python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
torch.manual_seed(42)
n = 3000

distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
value    = np.abs(np.random.normal(350, 150, n)).clip(50, 1200)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep, value])
y = delivery.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
X_mean, X_std = X_tr.mean(0), X_tr.std(0) + 1e-8
y_mean, y_std = y_tr.mean(), y_tr.std() + 1e-8
X_tr_t = torch.FloatTensor((X_tr - X_mean) / X_std)
y_tr_t = torch.FloatTensor((y_tr - y_mean) / y_std)
X_te_t = torch.FloatTensor((X_te - X_mean) / X_std)

# ── Without BatchNorm ──────────────────────────────────────────────────
model_no_bn = nn.Sequential(
    nn.Linear(4, 128), nn.ReLU(),
    nn.Linear(128, 64), nn.ReLU(),
    nn.Linear(64, 32), nn.ReLU(),
    nn.Linear(32, 1),
)

# ── With BatchNorm — placed BEFORE activation ─────────────────────────
# Standard order: Linear → BatchNorm → Activation
model_bn = nn.Sequential(
    nn.Linear(4, 128), nn.BatchNorm1d(128), nn.ReLU(),
    nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(),
    nn.Linear(64, 32), nn.BatchNorm1d(32), nn.ReLU(),
    nn.Linear(32, 1),
)

def train(model, epochs=100, lr=0.001):
    opt      = optim.Adam(model.parameters(), lr=lr)
    loss_fn  = nn.MSELoss()
    loader   = torch.utils.data.DataLoader(
        torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
        batch_size=64, shuffle=True,
    )
    train_losses, val_losses = [], []
    for epoch in range(epochs):
        model.train()
        for Xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(Xb), yb).backward()
            opt.step()
        model.eval()
        with torch.no_grad():
            train_losses.append(loss_fn(model(X_tr_t), y_tr_t).item())
            val_losses.append(loss_fn(model(X_te_t),
                torch.FloatTensor((y_te - y_mean) / y_std)).item())
    return train_losses, val_losses

print("Training comparison: with vs without BatchNorm")
print(f"{'Model':<20} {'Train loss':>12} {'Val loss':>10} {'Test MAE':>10}")
print("─" * 56)

for name, model in [('No BatchNorm', model_no_bn), ('With BatchNorm', model_bn)]:
    torch.manual_seed(42)
    tr_l, va_l = train(model)
    model.eval()
    with torch.no_grad():
        y_pred = model(X_te_t).numpy() * y_std + y_mean
    mae = mean_absolute_error(y_te, y_pred)
    print(f"  {name:<18}  {tr_l[-1]:>12.6f}  {va_l[-1]:>10.6f}  {mae:>10.4f}")

# ── BatchNorm has different behaviour in train vs eval mode ───────────
# Training: normalise using BATCH statistics (mean/std of current mini-batch)
# Eval:     normalise using RUNNING statistics (accumulated during training)
# THIS IS CRITICAL — forgetting model.eval() causes BatchNorm to use
# batch stats at inference time, which is wrong and non-deterministic

model_bn.train()   # BatchNorm uses batch statistics
model_bn.eval()    # BatchNorm uses running mean/var — correct for inference
print("
Critical: always call model.eval() before inference with BatchNorm.")
Technique 3 — prevent overfitting

Dropout — randomly disable neurons during training

Dropout is the simplest and most effective regularisation technique for neural networks. During each training step, each neuron is randomly set to zero with probability p (the dropout rate). The neuron produces no output and receives no gradient for that step. At test time, all neurons are active and their outputs are scaled by (1 − p) to compensate for the extra neurons.

Why does randomly disabling neurons help? It prevents co-adaptation — neurons learning to rely on specific other neurons. When any neuron might be absent, every neuron is forced to learn features that are individually useful. The result is an ensemble effect: each forward pass trains a slightly different sub-network, and the full network is an implicit average of all these sub-networks.

🧠 Analogy — read this first

A cricket team that always plays with the same 11 players develops tight co-dependences — the bowlers know exactly when the fielders will move. Now randomly sit out 3 players each practice session. Every player must become more versatile, able to cover gaps. The team becomes more robust. That is dropout.

In deep learning: each neuron must learn a feature that is useful regardless of which other neurons happen to be active. The network cannot rely on any specific path from input to output.

python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
torch.manual_seed(42)
n = 1000   # small dataset to demonstrate overfitting clearly

distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
value    = np.abs(np.random.normal(350, 150, n)).clip(50, 1200)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep, value])
y = delivery.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
X_mean, X_std = X_tr.mean(0), X_tr.std(0) + 1e-8
y_mean, y_std = y_tr.mean(), y_tr.std() + 1e-8
X_tr_t = torch.FloatTensor((X_tr - X_mean) / X_std)
y_tr_t = torch.FloatTensor((y_tr - y_mean) / y_std)
X_te_t = torch.FloatTensor((X_te - X_mean) / X_std)

def build_model(dropout_p=0.0):
    layers = []
    dims   = [4, 256, 256, 128, 64, 1]
    for i in range(len(dims) - 2):
        layers += [nn.Linear(dims[i], dims[i+1]), nn.ReLU()]
        if dropout_p > 0:
            layers.append(nn.Dropout(p=dropout_p))
    layers.append(nn.Linear(dims[-2], dims[-1]))
    return nn.Sequential(*layers)

def train_eval(model, epochs=150):
    opt     = optim.Adam(model.parameters(), lr=0.001)
    loss_fn = nn.MSELoss()
    loader  = torch.utils.data.DataLoader(
        torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
        batch_size=32, shuffle=True,
    )
    for _ in range(epochs):
        model.train()   # dropout ACTIVE during training
        for Xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(Xb), yb).backward()
            opt.step()

    model.eval()        # dropout INACTIVE during evaluation
    with torch.no_grad():
        tr_loss = loss_fn(model(X_tr_t), y_tr_t).item()
        val_loss = loss_fn(model(X_te_t),
            torch.FloatTensor((y_te - y_mean) / y_std)).item()
        y_pred  = model(X_te_t).numpy() * y_std + y_mean
    return tr_loss, val_loss, mean_absolute_error(y_te, y_pred)

print("Dropout effect on overfitting (large model, small dataset):")
print(f"{'Dropout p':<14} {'Train loss':>12} {'Val loss':>10} {'Gap':>8} {'Test MAE':>10}")
print("─" * 58)

for p in [0.0, 0.1, 0.3, 0.5, 0.7]:
    torch.manual_seed(42)
    tr, va, mae = train_eval(build_model(p))
    gap  = va - tr
    flag = ' ← overfit' if gap > 0.3 else ' ← underfit' if va > 1.0 else ''
    print(f"  p={p:<12}  {tr:>12.4f}  {va:>10.4f}  {gap:>8.4f}  {mae:>10.4f}{flag}")
Technique 4 — decay the learning rate as training matures

Learning rate schedules — start fast, finish precise

A high learning rate at the start of training is good — large steps explore the loss landscape quickly and escape bad initialisations. But a high learning rate near convergence is bad — the model bounces around the minimum without settling into it. Learning rate schedules reduce the learning rate as training progresses, combining fast early progress with precise final convergence.

StepLR

Multiply lr by gamma every step_size epochs. Simple and widely used. lr drops discretely — you see the loss decrease in jumps.

StepLR(optimizer, step_size=30, gamma=0.1)
lr: 0.01 → 0.001 (at epoch 30) → 0.0001 (at epoch 60)
CosineAnnealingLR

Smoothly decreases lr following a cosine curve from initial to eta_min over T_max epochs. Smooth and reliable — very widely used.

CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)
lr follows cos curve: fast decrease then slow settling
ReduceLROnPlateau

Monitor a metric (val loss). If it does not improve for "patience" epochs, reduce lr by factor. Adaptive — reacts to actual training dynamics.

ReduceLROnPlateau(optimizer, patience=10, factor=0.5)
lr only drops when training stalls — most efficient
OneCycleLR

Increase lr from base to max over first 30% of training, then decrease to near-zero. Based on the "super-convergence" phenomenon. Often fastest to converge.

OneCycleLR(optimizer, max_lr=0.01, total_steps=total)
warmup → peak → cooldown in one cycle
python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
torch.manual_seed(42)
n = 3000

distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
value    = np.abs(np.random.normal(350, 150, n)).clip(50, 1200)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep, value])
y = delivery.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
X_mean, X_std = X_tr.mean(0), X_tr.std(0) + 1e-8
y_mean, y_std = y_tr.mean(), y_tr.std() + 1e-8
X_tr_t = torch.FloatTensor((X_tr - X_mean) / X_std)
y_tr_t = torch.FloatTensor((y_tr - y_mean) / y_std)
X_te_t = torch.FloatTensor((X_te - X_mean) / X_std)
y_te_t = torch.FloatTensor((y_te - y_mean) / y_std)

EPOCHS = 100
BATCH  = 64
loader = torch.utils.data.DataLoader(
    torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
    batch_size=BATCH, shuffle=True,
)

def make_model():
    return nn.Sequential(
        nn.Linear(4, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.2),
        nn.Linear(64, 32), nn.BatchNorm1d(32), nn.ReLU(), nn.Dropout(0.2),
        nn.Linear(32, 1),
    )

def run_experiment(scheduler_name):
    torch.manual_seed(42)
    model     = make_model()
    loss_fn   = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=0.01)

    if scheduler_name == 'none':
        scheduler = None
    elif scheduler_name == 'step':
        scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
    elif scheduler_name == 'cosine':
        scheduler = optim.lr_scheduler.CosineAnnealingLR(
            optimizer, T_max=EPOCHS, eta_min=1e-6)
    elif scheduler_name == 'plateau':
        scheduler = optim.lr_scheduler.ReduceLROnPlateau(
            optimizer, patience=8, factor=0.5, verbose=False)
    elif scheduler_name == 'onecycle':
        scheduler = optim.lr_scheduler.OneCycleLR(
            optimizer, max_lr=0.01,
            steps_per_epoch=len(loader), epochs=EPOCHS)

    for epoch in range(EPOCHS):
        model.train()
        for Xb, yb in loader:
            optimizer.zero_grad()
            loss_fn(model(Xb), yb).backward()
            optimizer.step()
            if scheduler_name == 'onecycle':
                scheduler.step()   # OneCycleLR steps per batch

        if scheduler and scheduler_name != 'onecycle':
            if scheduler_name == 'plateau':
                model.eval()
                with torch.no_grad():
                    val_loss = loss_fn(model(X_te_t), y_te_t).item()
                scheduler.step(val_loss)
            else:
                scheduler.step()

    model.eval()
    with torch.no_grad():
        y_pred = model(X_te_t).numpy() * y_std + y_mean
    return mean_absolute_error(y_te, y_pred)

print("Learning rate schedule comparison:")
for name in ['none', 'step', 'cosine', 'plateau', 'onecycle']:
    mae = run_experiment(name)
    bar = '█' * int((6 - mae) * 10)
    print(f"  {name:<12}: MAE={mae:.4f} min  {bar}")
The production training recipe

All four techniques together — the standard modern training loop

In production, all four techniques are used simultaneously. AdamW as the optimizer, BatchNorm between linear layers and activations, Dropout after activations in hidden layers, CosineAnnealingLR or ReduceLROnPlateau for scheduling, and early stopping to prevent overfitting when validation loss stops improving. This is the recipe used in virtually every production MLP today.

python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import copy, warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
torch.manual_seed(42)
n = 3000

distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
value    = np.abs(np.random.normal(350, 150, n)).clip(50, 1200)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep, value])
y = delivery.reshape(-1, 1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

# Small val split from train for early stopping
X_tv, X_val, y_tv, y_val = train_test_split(X_tr, y_tr, test_size=0.15, random_state=42)
X_mean, X_std = X_tv.mean(0), X_tv.std(0) + 1e-8
y_mean, y_std = y_tv.mean(), y_tv.std() + 1e-8

to_t  = lambda arr: torch.FloatTensor((arr - X_mean) / X_std)
to_ty = lambda arr: torch.FloatTensor((arr - y_mean) / y_std)

X_tv_t  = to_t(X_tv);   y_tv_t  = to_ty(y_tv)
X_val_t = to_t(X_val);  y_val_t = to_ty(y_val)
X_te_t  = to_t(X_te)

# ── Production model — all four techniques ────────────────────────────
class ProductionMLP(nn.Module):
    def __init__(self, in_dim=4, hidden=[128, 64, 32], dropout=0.2):
        super().__init__()
        layers = []
        dims   = [in_dim] + hidden
        for i in range(len(dims) - 1):
            layers += [
                nn.Linear(dims[i], dims[i+1]),
                nn.BatchNorm1d(dims[i+1]),   # ← BatchNorm
                nn.ReLU(),
                nn.Dropout(dropout),          # ← Dropout
            ]
        layers.append(nn.Linear(dims[-1], 1))
        self.net = nn.Sequential(*layers)

    def forward(self, x):
        return self.net(x)

model     = ProductionMLP(dropout=0.2)
optimizer = optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)  # ← AdamW
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', patience=10, factor=0.5, verbose=False,
)
loss_fn = nn.MSELoss()
loader  = torch.utils.data.DataLoader(
    torch.utils.data.TensorDataset(X_tv_t, y_tv_t),
    batch_size=64, shuffle=True,
)

# ── Training loop with early stopping ─────────────────────────────────
best_val_loss   = float('inf')
best_model_wts  = copy.deepcopy(model.state_dict())
patience_count  = 0
PATIENCE        = 20

print("Training with all four techniques:")
print(f"{'Epoch':>7} {'Train loss':>12} {'Val loss':>10} {'LR':>12}")
print("─" * 46)

for epoch in range(1, 201):
    # ── Train ──────────────────────────────────────────────────────────
    model.train()
    for Xb, yb in loader:
        optimizer.zero_grad()
        loss_fn(model(Xb), yb).backward()
        optimizer.step()

    # ── Validate ───────────────────────────────────────────────────────
    model.eval()
    with torch.no_grad():
        tr_loss  = loss_fn(model(X_tv_t), y_tv_t).item()
        val_loss = loss_fn(model(X_val_t), y_val_t).item()

    scheduler.step(val_loss)   # ← LR schedule reacts to val loss
    current_lr = optimizer.param_groups[0]['lr']

    if epoch % 25 == 0:
        print(f"  {epoch:>5}  {tr_loss:>12.6f}  {val_loss:>10.6f}  {current_lr:>12.6f}")

    # ── Early stopping ─────────────────────────────────────────────────
    if val_loss < best_val_loss:
        best_val_loss  = val_loss
        best_model_wts = copy.deepcopy(model.state_dict())
        patience_count = 0
    else:
        patience_count += 1
        if patience_count >= PATIENCE:
            print(f"  Early stopping at epoch {epoch} — no improvement for {PATIENCE} epochs")
            break

# ── Restore best model and evaluate ───────────────────────────────────
model.load_state_dict(best_model_wts)
model.eval()
with torch.no_grad():
    y_pred = model(X_te_t).numpy() * y_std + y_mean

mae = mean_absolute_error(y_te, y_pred)
print(f"
Final test MAE: {mae:.4f} minutes")
print(f"Best val loss:  {best_val_loss:.6f}")
Errors you will hit

Every common training technique mistake — explained and fixed

Model gives different predictions every time you call it — non-deterministic inference
Why it happens

Dropout is active during inference. You forgot to call model.eval() before generating predictions. In training mode, Dropout randomly zeros activations — so the same input produces different outputs on every call. BatchNorm also behaves differently in train vs eval mode.

Fix

Always call model.eval() before any inference: model.eval(); with torch.no_grad(): y_pred = model(X). The with torch.no_grad() also prevents unnecessary gradient computation. Pair model.eval() with model.train() when resuming training. A common pattern: wrap all evaluation code in a context manager that calls eval() and restores train() afterward.

BatchNorm raises ValueError: Expected more than 1 value per channel when training
Why it happens

A mini-batch of size 1 was passed to a model containing BatchNorm1d. BatchNorm computes the mean and variance of the batch — with only 1 sample, the variance is zero and normalisation is undefined. This happens when the last batch of an epoch has exactly 1 sample (dataset size not divisible by batch_size).

Fix

Add drop_last=True to the DataLoader: DataLoader(dataset, batch_size=64, shuffle=True, drop_last=True). This drops the last incomplete batch. Alternatively use larger batch sizes so the probability of a size-1 batch is zero. Or switch to LayerNorm which normalises per sample rather than per batch — does not have this issue.

Learning rate scheduler is not reducing the learning rate as expected
Why it happens

For ReduceLROnPlateau, you are calling scheduler.step() without passing the monitored metric. The scheduler does not know whether performance improved. For epoch-based schedulers (StepLR, CosineAnnealingLR), you are calling scheduler.step() inside the batch loop instead of once per epoch — the lr decays too fast.

Fix

For ReduceLROnPlateau: scheduler.step(val_loss) — pass the metric every epoch. For StepLR/CosineAnnealingLR: call scheduler.step() exactly once per epoch, after the training loop. For OneCycleLR: call scheduler.step() after every batch. Print optimizer.param_groups[0]['lr'] every 10 epochs to verify lr is actually changing.

Model overfits despite Dropout — train loss near zero, val loss 5× higher
Why it happens

Dropout probability is too low, the model is too large for the dataset, or Dropout is placed in the wrong position. Common mistake: placing Dropout before BatchNorm — BatchNorm re-normalises after Dropout, reducing its regularisation effect. Also: Dropout is not a substitute for reducing model size when the model is vastly over-parameterised.

Fix

Increase dropout probability (try 0.3–0.5). Place Dropout after activation, not before BatchNorm: Linear → BatchNorm → ReLU → Dropout. Reduce model width or depth. Add weight_decay=0.01 in AdamW. For very small datasets (under 500 samples), even these may not be enough — use a much smaller model or switch to classical ML which generalises better with few samples.

What this looks like at work

The bug almost every team hits once: a model that scores perfectly offline and randomly in production

A fraud-scoring service pages the on-call engineer: the same transaction, scored twice within a second of each other, comes back with two different fraud probabilities. The model behaved perfectly in every offline evaluation notebook. Nothing about the model itself changed between training and deployment. The cause, once found, is one missing line: the serving code never called model.eval().

With Dropout still active, every forward call randomly zeroes a different 30 percent of neurons — the exact same input genuinely produces a different output every time, by design. With BatchNorm still in training mode, each call also normalises using whatever small batch happened to arrive with that request instead of the stable running statistics learned during training. Both bugs look identical from the outside: non-deterministic, batch-size-dependent predictions that pass every offline test, because offline evaluation almost always happens with eval() already set, even by accident.

python
import torch
import torch.nn as nn

# ── The pattern that prevents the "forgot model.eval()" incident ───────
# Real incident: a fraud-scoring service returned a different score for
# the exact same transaction on every request. Root cause: the model was
# loaded once at startup and reused across requests, but nothing ever
# called model.eval() — so Dropout stayed active in "production" mode,
# silently randomising every prediction downstream teams depended on.

class FraudScorer(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(12, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(64, 32), nn.BatchNorm1d(32), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(32, 1),
        )
    def forward(self, x):
        return self.net(x)

def load_for_serving(checkpoint_path: str) -> nn.Module:
    """The fix: load, THEN force eval mode, THEN assert it stuck."""
    model = FraudScorer()
    # model.load_state_dict(torch.load(checkpoint_path))   # real checkpoint load
    model.eval()
    assert not model.training, "model must be in eval mode before serving"
    return model

model = load_for_serving('fraud_scorer.pt')

torch.manual_seed(0)
same_transaction = torch.randn(1, 12)

# Same input, called three times — must return the SAME score every time
scores = [model(same_transaction).item() for _ in range(3)]
print(f"Scores for the identical transaction, three separate calls: {scores}")
print("Identical every time, because model.eval() disabled Dropout and")
print("switched BatchNorm over to its stored running statistics.")

print("
Without load_for_serving()'s model.eval() call, the three scores")
print("above would differ from each other — Dropout would zero a different")
print("random 30% of neurons on every single call, exactly reproducing the")
print("incident that paged the on-call engineer in the first place.")

Once the train/eval bug is fixed, the remaining production decisions are about where BatchNorm and Dropout go and how strong to make them — and both answers depend heavily on what kind of model and batch size you actually have.

BatchNorm is not universal — what production teams swap in by domain
Tabular / MLP (batch 32-256)BatchNorm1d

The case this module covers — batch statistics are stable and meaningful at these batch sizes.

Object detection / segmentation (large images, batch 2-8 per GPU)GroupNorm or SyncBatchNorm

GPU memory limits batch size so much that per-GPU batch statistics become noisy and unstable. GroupNorm normalises within groups of channels instead, independent of batch size.

Transformers / sequence modelsLayerNorm

Sequences have variable length and no meaningful shared batch statistic per position — LayerNorm normalises across the feature dimension per token instead.

Style transfer / image generationInstanceNorm

Normalises per-sample, per-channel, ignoring the batch entirely — batch statistics would otherwise leak style information between unrelated images in the same batch.

Dropout rates production teams actually default to, by position
Input layerRarely used, 0.0-0.1 at most

Too aggressive here destroys raw signal before the network has done anything with it.

Early / middle hidden layers (MLP)0.2-0.3

The default this module trains with — enough regularisation without starving the network of capacity.

Layer right before the output0.4-0.5

The most overfitting-prone junction, closest to memorising training examples directly.

Conv layers (CNN feature extractor)Often skipped, or Dropout2d if used

Most CNNs lean on BatchNorm plus data augmentation instead. Plain Dropout zeroes individual pixels, which barely changes a spatially correlated feature map — Dropout2d zeroes whole channels instead.

Transformer blocks (attention + FFN)Flat 0.1

The exact default from the original Attention Is All You Need paper, still used by most transformer implementations today.

Misconceptions

Five things people get wrong about Adam, BatchNorm, and Dropout

Myth: Adam is strictly better than SGD, so there is never a reason to use plain SGD

Adam usually converges faster and is far more forgiving of learning rate choice, which is why it is the default in almost every tutorial. But a substantial body of empirical results — especially in computer vision, where ResNets and other CNNs are still frequently trained with SGD+momentum — shows plain SGD often finds flatter minima that generalise slightly better on the test set, while Adam's per-parameter adaptive scaling can settle into sharper minima that fit the training set very well but generalise worse. Reach for Adam/AdamW for fast iteration and for transformers, but do not assume it dominates SGD+momentum for every architecture and dataset.

Myth: BatchNorm works by fixing 'internal covariate shift'

This is the original justification from the 2015 BatchNorm paper, and it is what most tutorials repeat — but it turned out to be largely wrong. A widely cited 2018 study (Santurkar et al.) showed BatchNorm's real benefit has little to do with stabilising the distribution of layer inputs: networks with BatchNorm still show similar internal covariate shift when noise is deliberately reintroduced, yet still train faster and more stably. The actual mechanism appears to be that BatchNorm smooths the loss landscape, letting you use larger learning rates safely. The practical guidance is unchanged, but "fixes internal covariate shift" is the wrong mental model for why it works.

Myth: BatchNorm and Dropout are interchangeable regularisers, so it doesn't matter how you combine them

They solve different problems — BatchNorm stabilises the scale of activations between layers, Dropout prevents neurons from co-adapting — and stacking them naively can actively hurt training. If Dropout runs before BatchNorm, BatchNorm computes its batch statistics from a randomly zeroed-out activation, and at test time (no dropout) those statistics no longer match what the layer actually sees — a mismatch researchers call variance shift. The safe, standard ordering is Linear → BatchNorm → Activation → Dropout, keeping Dropout after normalisation so it cannot corrupt BatchNorm's running statistics.

Myth: a higher dropout rate is always safer because it means more regularisation

Dropout probability is not a dial you can crank up "to be safe" — push it too high (0.7–0.8 in most hidden layers) and the network underfits: too few active neurons remain each step to learn a useful representation at all, and training slows because the gradient signal becomes noisy from the reduced effective batch of active units. It also is not applied uniformly by layer type in practice: standard Dropout zeroes individual activations, which works well for fully connected layers but is far less effective in convolutional layers, where entire spatial regions need to be dropped together (Dropout2d) since adjacent pixels are highly correlated and one zeroed pixel gives almost no information loss.

Myth: once you use Adam you no longer need a learning rate schedule, since Adam already 'adapts' the learning rate

Adam's adaptivity is per-parameter and relative — it rescales each weight's step by that weight's own gradient history, not a global decay over training time. It does nothing to address the separate need for large exploratory steps early in training and small precise steps near convergence. This is why virtually every state-of-the-art recipe pairs Adam/AdamW with an explicit schedule (cosine annealing, ReduceLROnPlateau, warmup + decay) on top of it — the two mechanisms solve different problems and stack rather than substitute for each other.

Interview prep

Training techniques — 5 questions interviewers actually ask

Q1 — Walk me through exactly what changes in a BatchNorm layer between model.train() and model.eval()

During training, BatchNorm normalises each mini-batch using that batch's own mean and variance, computed on the fly, and simultaneously updates a running exponential moving average of the mean and variance it will need later. During evaluation, it stops computing batch statistics altogether and instead normalises using those accumulated running statistics — necessary because at inference you may see a single example at a time, where a "batch mean and variance" would be meaningless. Forgetting model.eval() before inference is one of the most common real PyTorch bugs: predictions become non-deterministic and batch-size-dependent because BatchNorm is still using per-call batch statistics instead of the stable learned ones.

Q2 — Why can Adam, BatchNorm, and Dropout sometimes hurt each other when combined naively, and how do you combine them correctly?

Each was designed and validated independently, and their interactions are not guaranteed to be additive. The concrete failure mode is Dropout randomly zeroing activations right before BatchNorm computes statistics on them — BatchNorm's running mean/variance during training then reflect a "with holes" distribution that no longer matches the full, dropout-free distribution seen at test time, producing a train/test mismatch called variance shift. The fix is ordering: Linear → BatchNorm → Activation → Dropout, so BatchNorm always normalises the true activations, and only afterward does Dropout randomly zero some of them for regularisation. Since BatchNorm already stabilises gradient scale, some architectures also need a lower Adam learning rate than a BatchNorm-free equivalent to avoid overshooting.

Q3 — You're debugging a model that trained beautifully but gives wildly inconsistent predictions in production on the exact same input. What's your process?

First check the obvious: is model.eval() being called before inference? If the model has Dropout or BatchNorm layers and eval mode was never set, Dropout will still randomly zero neurons on every call by design, and BatchNorm will compute statistics from whatever single example or small batch happens to be in front of it rather than the stable running statistics — both produce exactly this symptom. Second, verify torch.no_grad() wraps inference so no gradient state leaks in. Third, confirm the serving pipeline applies the exact same normalisation and preprocessing used during training — a mismatched mean/std standardisation looks identical to this failure mode from the outside.

Q4 — What's the actual difference between Adam with weight_decay and AdamW, and why does it matter enough that AdamW became the default?

Standard Adam implements weight decay by adding the L2 penalty term directly to the gradient before Adam's adaptive scaling is applied — so the effective decay strength ends up divided by each parameter's own adaptive learning rate, meaning parameters with historically large gradients get proportionally less regularisation than intended, an interaction nobody wanted. AdamW decouples this: it applies weight decay directly to the weights during the update step, after the adaptive gradient step, so every parameter gets the same decay rate regardless of its gradient history. This matters enough in practice (better generalisation, especially in transformers) that AdamW, not Adam, is now the default optimiser in essentially every modern training recipe.

Q5 — For a new computer vision project, would you reach for SGD+momentum or AdamW as your default optimiser, and what would change your mind?

I would usually start with AdamW for the initial working pipeline — it's far more forgiving of learning rate choice and gets a model training end-to-end fastest, which matters most while I'm still debugging the pipeline itself. Once it works and I'm optimising for the best possible final accuracy on a well-understood CNN architecture, I'd try SGD with momentum and a cosine or step schedule as a second pass, since this combination often reaches slightly better generalisation on image classification benchmarks than Adam-family optimisers, at the cost of being more sensitive to learning rate and needing a longer schedule to converge.

What comes next

You can train deep MLPs reliably. Next: convolutional networks for images.

The MLP you have built is a general-purpose network — it works on tabular data, but it is not the right architecture for images. Images have spatial structure: nearby pixels are related, patterns appear at different positions in the image. A fully connected layer treats every pixel independently and ignores this structure. Convolutional Neural Networks (CNNs) are designed specifically to exploit spatial structure — they are the backbone of every image classification, object detection, and medical imaging system.

Next — Module 43 · Deep Learning
Convolutional Neural Networks — Image Classification

Filters, feature maps, pooling, and how CNNs learn to recognise objects at any position in an image.

Start →

🎯 Key Takeaways

  • Adam maintains a separate adaptive learning rate per weight based on the history of gradients. Weights that receive large consistent gradients get smaller steps. Weights with rare small gradients get larger steps. Use AdamW (Adam with correct weight decay) as the default optimizer for all deep learning.
  • Batch Normalisation normalises activations at each layer to zero mean and unit variance within each mini-batch, then applies learnable scale (gamma) and shift (beta) parameters. Prevents internal covariate shift, makes very deep networks trainable, and acts as mild regularisation. Always call model.eval() before inference — BatchNorm behaves differently in train vs eval mode.
  • Dropout randomly zeros a fraction p of neurons during each training step, forcing the network to not rely on any specific path. At inference, all neurons are active and outputs are scaled by (1-p). Place Dropout after activation functions, not before BatchNorm. Typical values: p=0.2 for hidden layers, p=0.5 for the final hidden layer.
  • Learning rate schedules start with a higher lr for fast early progress and reduce it as training matures for precise final convergence. ReduceLROnPlateau is the most robust — it only reduces lr when validation loss stops improving. OneCycleLR often converges fastest. Always monitor optimizer.param_groups[0]["lr"] to verify the schedule is working.
  • Early stopping is essential — monitor validation loss and stop training when it has not improved for "patience" epochs, then restore the best weights. This prevents overfitting without needing to guess the right number of epochs in advance.
  • The production training recipe: AdamW + BatchNorm + Dropout + ReduceLROnPlateau + early stopping. These five components are the standard in virtually every production MLP. Start with lr=0.001, dropout=0.2, weight_decay=0.01, and tune from there.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...