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

Optimisers — SGD, Adam, AdamW

Momentum, adaptive learning rates, and weight decay done right. Why AdamW replaced Adam as the default and when SGD still wins.

26–34 min March 2026
Before any formula — what problem does an optimiser solve?

Backpropagation tells you which direction to move each weight. The optimiser decides how far to move — and how to move smarter than just "subtract the gradient."

After backpropagation you have a gradient for every weight — the direction in which each weight should change to reduce the loss. The simplest possible update: subtract a small fraction of the gradient. That fraction is the learning rate. This is plain SGD. It works, but it has two major problems in practice.

First: the same learning rate for every weight. A weight that receives large, consistent gradient signals needs smaller steps to avoid overshooting. A weight that receives rare, tiny gradients needs larger steps to make any progress. Treating all weights the same wastes most of the gradient signal.

Second: gradient noise. Mini-batch gradients are noisy estimates of the true gradient. A single step in a noisy direction wastes a step. Accumulating direction from many past steps — momentum — filters noise and accelerates progress. Modern optimisers (Adam, AdamW) solve both problems simultaneously.

🧠 Analogy — read this first

SGD is like hiking downhill in thick fog with one step at a time — you only see the slope directly under your feet right now. SGD with momentum is like a ball rolling downhill — it accumulates speed in consistent directions and is slowed less by small bumps. Adam is a smart hiker with a map of the terrain history — they take big steps on flat ground and small careful steps on steep or unpredictable terrain.

AdamW is Adam who also carries a light backpack that gets heavier the further they walk — gently pulling them back toward the origin (weight decay) to prevent them from wandering too far.

🎯 Pro Tip
Default choice for almost all deep learning: AdamW with lr=1e-3 and weight_decay=0.01. Start here. Only switch to SGD+momentum when you have evidence it generalises better on your specific task — which happens mainly for large-scale image classification.
The foundation

SGD and momentum — from naive update to direction accumulation

Plain SGD is the simplest possible optimiser: subtract learning_rate × gradient from each weight every step. Momentum extends this by accumulating a velocity — a weighted average of all past gradients. Instead of updating directly from the current gradient, you update from the velocity, which smooths out noise and accelerates in consistent directions.

SGD vs SGD with momentum — the update equations
Plain SGD:
W = W − lr × g
SGD with momentum:
v = β × v + g ← accumulate velocity (β=0.9 typical)
W = W − lr × v
β=0.9: 90% of previous velocity + 10% of new gradient.
Consistent gradients accumulate — speed builds up.
Noisy gradients cancel — oscillation dampened.
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 = 2000

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 make_model():
    torch.manual_seed(42)
    return nn.Sequential(
        nn.Linear(4, 64), nn.ReLU(),
        nn.Linear(64, 32), nn.ReLU(),
        nn.Linear(32, 1),
    )

def train_and_eval(optimizer_fn, epochs=150):
    model   = make_model()
    opt     = optimizer_fn(model.parameters())
    loss_fn = nn.MSELoss()
    loader  = torch.utils.data.DataLoader(
        torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
        batch_size=64, shuffle=True,
    )
    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():
            losses.append(loss_fn(model(X_tr_t), y_tr_t).item())

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

# ── Compare SGD variants ───────────────────────────────────────────────
configs = {
    'SGD (lr=0.01)':              lambda p: optim.SGD(p, lr=0.01),
    'SGD (lr=0.1)':               lambda p: optim.SGD(p, lr=0.1),
    'SGD + momentum (β=0.9)':     lambda p: optim.SGD(p, lr=0.01, momentum=0.9),
    'SGD + momentum + nesterov':  lambda p: optim.SGD(p, lr=0.01,
                                      momentum=0.9, nesterov=True),
}

print(f"{'Optimiser':<32} {'Epoch 10 loss':>14} {'Final loss':>12} {'Test MAE':>10}")
print("─" * 72)
for name, opt_fn in configs.items():
    losses, mae = train_and_eval(opt_fn)
    print(f"  {name:<30}  {losses[9]:>14.6f}  {losses[-1]:>12.6f}  {mae:>10.4f}")

# ── Implement SGD + momentum from scratch ─────────────────────────────
print("
SGD + momentum from scratch (verify same result as PyTorch):")
W = np.array([1.0, 2.0, -1.0])
g_history = [np.array([0.5, -0.3, 0.8]),
             np.array([0.4, -0.2, 0.7]),
             np.array([0.6, -0.4, 0.9])]

lr, beta = 0.1, 0.9
v = np.zeros_like(W)
for step, g in enumerate(g_history):
    v = beta * v + g
    W = W - lr * v
    print(f"  Step {step+1}: v={v.round(4)}  W={W.round(4)}")
The adaptive optimiser

Adam — per-weight adaptive learning rates via first and second moments

Adam (Adaptive Moment Estimation) maintains two running statistics per weight: the first moment (exponential moving average of gradients — like momentum) and the second moment (exponential moving average of squared gradients — measures how large gradients have been historically). The effective learning rate for each weight is lr / √(second moment) — weights with large past gradients get a smaller effective step size automatically.

Adam update — six lines, fully explained
Per weight, per step t:
m = β₁ × m + (1−β₁) × g ← 1st moment (momentum-like)
v = β₂ × v + (1−β₂) × g² ← 2nd moment (gradient variance)
m̂ = m / (1−β₁ᵗ) ← bias correction (early steps)
v̂ = v / (1−β₂ᵗ)
W = W − lr × m̂ / (√v̂ + ε) ← adaptive update
Defaults: β₁=0.9 β₂=0.999 ε=1e-8 lr=1e-3
ε prevents division by zero when v̂ is near zero (sparse gradients)
python
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import warnings
warnings.filterwarnings('ignore')

# ── Adam from scratch — every step visible ────────────────────────────
class AdamScratch:
    def __init__(self, params, lr=0.001, betas=(0.9, 0.999), eps=1e-8):
        self.params = list(params)
        self.lr     = lr
        self.b1     = betas[0]
        self.b2     = betas[1]
        self.eps    = eps
        self.t      = 0
        # Initialise moment estimates to zero for every parameter
        self.m = [np.zeros_like(p) for p in self.params]
        self.v = [np.zeros_like(p) for p in self.params]

    def step(self, grads):
        self.t += 1
        for i, (p, g) in enumerate(zip(self.params, grads)):
            # Update biased first moment estimate
            self.m[i] = self.b1 * self.m[i] + (1 - self.b1) * g
            # Update biased second raw moment estimate
            self.v[i] = self.b2 * self.v[i] + (1 - self.b2) * g**2
            # Bias-corrected estimates
            m_hat = self.m[i] / (1 - self.b1**self.t)
            v_hat = self.v[i] / (1 - self.b2**self.t)
            # Update parameter
            p -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)

# Verify Adam scratch matches PyTorch
np.random.seed(0)
W_scratch = np.array([1.0, -0.5, 0.8, -1.2])
W_torch   = torch.tensor(W_scratch.copy(), requires_grad=True)

adam_scratch = AdamScratch([W_scratch], lr=0.001)
adam_torch   = optim.Adam([W_torch], lr=0.001)

# Simulate 5 gradient steps
for step in range(5):
    g_np = np.random.randn(4) * 0.5
    g_pt = torch.tensor(g_np.copy())

    # Scratch
    adam_scratch.step([g_np])

    # PyTorch
    adam_torch.zero_grad()
    W_torch.grad = g_pt
    adam_torch.step()

    diff = np.abs(W_scratch - W_torch.detach().numpy()).max()
    print(f"Step {step+1}: max diff scratch vs PyTorch = {diff:.2e}  "
          f"{'✓' if diff < 1e-6 else '✗'}")

# ── Why bias correction matters at the start ──────────────────────────
print("
Bias correction effect (first 5 steps, single weight):")
print(f"{'Step':>5} {'m (biased)':>12} {'m_hat (corrected)':>18} {'ratio':>8}")
b1, g = 0.9, 1.0
m = 0.0
for t in range(1, 6):
    m     = b1 * m + (1 - b1) * g
    m_hat = m / (1 - b1**t)
    print(f"  {t:>3}   {m:>12.6f}   {m_hat:>16.6f}   {m_hat/g:>7.4f}")
print("Without correction, first steps are too small (m ≈ 0.1 vs true ≈ 1.0)")
The important difference

Adam vs AdamW — why weight decay was broken in Adam

In standard SGD, L2 regularisation (adding λ||W||² to the loss) and weight decay (subtracting λW from the weight directly) are mathematically equivalent. In Adam they are not — and this caused Adam's weight decay to be effectively much weaker than intended for years before anyone noticed.

The problem: in Adam, the L2 gradient λW gets divided by √v̂ just like any other gradient — weights with large historical gradients get a smaller effective weight decay than weights with small gradients. The regularisation strength varies per weight in an uncontrolled way. AdamW (Loshchilov and Hutter, 2019) fixes this by decoupling weight decay from the gradient update — applying it directly to the weight before the adaptive gradient step.

Adam vs AdamW — the one-line difference
Adam: W = W − lr × m̂/(√v̂+ε) − lr × λ × m̂_decay/(√v̂+ε)
↑ weight decay scaled by adaptive lr — uncontrolled
AdamW: W = W − lr × λ × W ← decouple first
W = W − lr × m̂/(√v̂+ε) ← then adapt
AdamW weight decay is uniform — every weight shrinks by the same fraction λ regardless of its gradient history. This is what weight decay should do.
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 = 2000

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 make_model():
    torch.manual_seed(42)
    return nn.Sequential(
        nn.Linear(4, 64), nn.ReLU(),
        nn.Linear(64, 32), nn.ReLU(),
        nn.Linear(32, 1),
    )

def train_eval(opt_fn, epochs=150):
    model   = make_model()
    opt     = opt_fn(model.parameters())
    loss_fn = nn.MSELoss()
    loader  = torch.utils.data.DataLoader(
        torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
        batch_size=64, shuffle=True,
    )
    for _ 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():
        y_pred = model(X_te_t).numpy() * y_std + y_mean
        # Weight norm — smaller = more regularised
        w_norm = sum(p.norm().item() for p in model.parameters())
    return mean_absolute_error(y_te, y_pred), w_norm

print(f"{'Optimiser':<35} {'Test MAE':>10} {'Weight norm':>12}")
print("─" * 62)

configs = {
    'SGD (lr=0.01, mom=0.9)':         lambda p: optim.SGD(p, lr=0.01, momentum=0.9),
    'Adam (lr=1e-3)':                  lambda p: optim.Adam(p, lr=1e-3),
    'Adam (lr=1e-3, wd=0.01)':         lambda p: optim.Adam(p, lr=1e-3, weight_decay=0.01),
    'AdamW (lr=1e-3, wd=0.01)':        lambda p: optim.AdamW(p, lr=1e-3, weight_decay=0.01),
    'AdamW (lr=1e-3, wd=0.1)':         lambda p: optim.AdamW(p, lr=1e-3, weight_decay=0.1),
}

for name, opt_fn in configs.items():
    mae, w_norm = train_eval(opt_fn)
    print(f"  {name:<33}  {mae:>10.4f}  {w_norm:>12.4f}")

print("
Observation: AdamW weight norm is smaller than Adam with same wd")
print("— decoupled weight decay is more effective regularisation.")
Making the learning rate dynamic

Learning rate schedules — warmup, cosine decay, and ReduceLROnPlateau

The learning rate is the single most important hyperparameter. A fixed learning rate is always a compromise — too high early on causes divergence, too low late in training means slow progress. Schedules give you the best of both: a high rate for fast early exploration and a low rate for precise final convergence.

Linear warmup is especially important for Adam-based optimisers. In the first steps, the second moment estimate v is near zero — the bias correction denominator (1−β₂ᵗ) is small, making v̂ small, making the effective learning rate very large. Warmup starts with a tiny learning rate and gradually increases it, preventing unstable large updates in the first steps.

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 = 2000

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 = 150
BATCH  = 64
loader = torch.utils.data.DataLoader(
    torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
    batch_size=BATCH, shuffle=True,
)

def run(scheduler_name, epochs=EPOCHS):
    torch.manual_seed(42)
    model   = nn.Sequential(
        nn.Linear(4,64), nn.ReLU(),
        nn.Linear(64,32), nn.ReLU(),
        nn.Linear(32,1),
    )
    loss_fn = nn.MSELoss()
    opt     = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)

    if scheduler_name == 'none':
        sched = None
    elif scheduler_name == 'cosine':
        sched = optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs, eta_min=1e-6)
    elif scheduler_name == 'step':
        sched = optim.lr_scheduler.StepLR(opt, step_size=50, gamma=0.1)
    elif scheduler_name == 'plateau':
        sched = optim.lr_scheduler.ReduceLROnPlateau(
            opt, patience=10, factor=0.5, verbose=False)
    elif scheduler_name == 'onecycle':
        sched = optim.lr_scheduler.OneCycleLR(
            opt, max_lr=1e-2,
            steps_per_epoch=len(loader), epochs=epochs)
    elif scheduler_name == 'warmup_cosine':
        # Linear warmup for first 10% of steps, then cosine decay
        total_steps  = epochs * len(loader)
        warmup_steps = int(0.1 * total_steps)
        def lr_lambda(step):
            if step < warmup_steps:
                return step / warmup_steps
            progress = (step - warmup_steps) / (total_steps - warmup_steps)
            return 0.5 * (1 + np.cos(np.pi * progress))
        sched = optim.lr_scheduler.LambdaLR(opt, lr_lambda)

    for epoch in range(epochs):
        model.train()
        for Xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(Xb), yb).backward()
            opt.step()
            if scheduler_name in ('onecycle', 'warmup_cosine'):
                sched.step()

        if sched and scheduler_name not in ('onecycle', 'warmup_cosine'):
            if scheduler_name == 'plateau':
                model.eval()
                with torch.no_grad():
                    val = loss_fn(model(X_te_t), y_te_t).item()
                sched.step(val)
            else:
                sched.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 (AdamW base):")
for name in ['none','cosine','step','plateau','onecycle','warmup_cosine']:
    mae = run(name)
    bar = '█' * int((5.5 - mae) * 12)
    print(f"  {name:<16}: MAE={mae:.4f}  {bar}")

# ── LR warmup: why it matters ─────────────────────────────────────────
print("
LR warmup — effective lr at step t (AdamW, β₂=0.999):")
lr, b2 = 1e-3, 0.999
warmup = 100
for t in [1, 5, 10, 50, 100, 200]:
    bias_correction = np.sqrt(1 - b2**t)
    eff_lr_no_warmup = lr / bias_correction
    warmup_factor    = min(t / warmup, 1.0)
    eff_lr_warmup    = lr * warmup_factor / bias_correction
    print(f"  step {t:>4}: no warmup={eff_lr_no_warmup:.6f}  "
          f"with warmup={eff_lr_warmup:.6f}")
The exception to the rule

When SGD+momentum beats Adam — and why generalisation differs

Adam converges faster in almost every setting. But on large-scale image classification (ImageNet-scale CNNs) and some NLP tasks, SGD+momentum often achieves better final test accuracy despite slower convergence. This is a known phenomenon with a theoretical explanation: Adam finds sharp minima (narrow valleys in the loss landscape) while SGD tends to find flat minima. Flat minima generalise better because small perturbations to weights — which happen naturally when data distribution shifts slightly — do not change the loss much. Sharp minima are sensitive to such perturbations.

AdamW
WINS FOR
Transformers (BERT, GPT)
Small-to-medium MLPs
Fast prototyping
NLP fine-tuning
Tabular deep learning
WEAKER FOR
Large-scale image CNNs at final top-1 accuracy
Some RL tasks
Typical: lr=1e-3, β₁=0.9, β₂=0.999, wd=0.01
SGD + momentum
WINS FOR
ImageNet CNNs (ResNet, EfficientNet)
Fine-tuning pretrained vision
When generalisation gap matters most
WEAKER FOR
Slow to converge
Sensitive to lr choice
Bad for sparse gradients
Typical: lr=0.1, momentum=0.9, wd=1e-4, with cosine schedule
python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')

# ── Head-to-head on a structured classification task ───────────────────
torch.manual_seed(42)
np.random.seed(42)

X_raw, y_raw = make_classification(
    n_samples=3000, n_features=20, n_informative=10,
    n_redundant=5, random_state=42,
)
X_tr_np, X_te_np, y_tr_np, y_te_np = train_test_split(
    X_raw, y_raw, test_size=0.2, stratify=y_raw, random_state=42
)
sc = StandardScaler()
X_tr_t = torch.FloatTensor(sc.fit_transform(X_tr_np))
X_te_t = torch.FloatTensor(sc.transform(X_te_np))
y_tr_t = torch.FloatTensor(y_tr_np)
y_te_t = torch.FloatTensor(y_te_np)

def run_cls(opt_name, epochs=200):
    torch.manual_seed(42)
    model   = nn.Sequential(
        nn.Linear(20, 128), nn.ReLU(),
        nn.Linear(128, 64), nn.ReLU(),
        nn.Linear(64, 1),
    )
    loss_fn = nn.BCEWithLogitsLoss()
    loader  = torch.utils.data.DataLoader(
        torch.utils.data.TensorDataset(X_tr_t, y_tr_t),
        batch_size=64, shuffle=True,
    )

    if opt_name == 'sgd':
        opt   = optim.SGD(model.parameters(), lr=0.01,
                           momentum=0.9, weight_decay=1e-4)
        sched = optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
    elif opt_name == 'adam':
        opt   = optim.Adam(model.parameters(), lr=1e-3)
        sched = None
    else:
        opt   = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
        sched = optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)

    best_val, best_epoch = 0.0, 0
    for epoch in range(epochs):
        model.train()
        for Xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(Xb).squeeze(), yb).backward()
            opt.step()
        if sched:
            sched.step()

        model.eval()
        with torch.no_grad():
            val_acc = ((model(X_te_t).squeeze() > 0).float() == y_te_t).float().mean().item()
        if val_acc > best_val:
            best_val, best_epoch = val_acc, epoch + 1

    return best_val, best_epoch

print(f"{'Optimiser':<20} {'Best val acc':>13} {'At epoch':>10}")
print("─" * 46)
for name in ['sgd', 'adam', 'adamw']:
    acc, ep = run_cls(name)
    print(f"  {name:<18}  {acc:>13.4f}  {ep:>10}")
Errors you will hit

Every common optimiser mistake — explained and fixed

Loss oscillates wildly and never converges — high variance across steps
Why it happens

Learning rate is too high. With a large lr, gradient steps overshoot the minimum and bounce between sides of the loss valley. This is especially visible with SGD — Adam is more robust to high lr because the second moment normalises the step size. With SGD and lr=0.1 on a problem that needs lr=0.001, loss will oscillate between high values indefinitely.

Fix

Reduce learning rate by 10×. A good diagnostic: if loss decreases in the first few steps then suddenly spikes, the lr is too high. Use ReduceLROnPlateau as a safety net — it automatically halves lr when loss stops improving. For Adam-based optimisers start at lr=1e-3. For SGD start at lr=0.01 and use cosine decay.

optimizer.zero_grad() is called after loss.backward() instead of before — gradients accumulate
Why it happens

The correct order is: zero_grad() → forward() → loss() → backward() → step(). Calling zero_grad() after backward() clears the gradients that were just computed before step() uses them. The weight update happens with the accumulated gradients from previous steps — training with wrong gradients from the very first step.

Fix

Always follow this exact order every training step: optimizer.zero_grad(); output = model(X); loss = criterion(output, y); loss.backward(); optimizer.step(). Never rearrange these four lines. A common mnemonic: Zero, Forward, Backward, Step — ZFBS.

AdamW weight_decay has no visible effect — weights are not shrinking
Why it happens

weight_decay is too small relative to the learning rate and gradient magnitude. With lr=1e-3 and weight_decay=1e-5, the weight decay term subtracts only 1e-8 per step — negligible compared to gradient updates. Also: using Adam (not AdamW) means weight decay is folded into the gradient and effectively scaled down by the adaptive lr.

Fix

For AdamW, typical weight_decay values are 0.01 to 0.1. weight_decay=1e-5 is essentially no regularisation. Check that you are using optim.AdamW not optim.Adam — they have the same API but AdamW applies weight decay correctly. Verify by printing the L2 norm of weights before and after 100 steps: it should decrease with AdamW.

Model trained with Adam overfits but SGD version does not on the same architecture
Why it happens

Adam finds sharp minima — narrow valleys in the loss landscape that fit training data very well but do not generalise. SGD with momentum has an implicit bias toward flatter minima due to its noisier trajectory. This is not a bug in Adam — it is a known property. On large models with limited data, Adam's tendency toward sharp minima causes worse generalisation.

Fix

Add weight decay: optim.AdamW with weight_decay=0.01–0.1 pulls weights toward zero which flattens the effective loss landscape. Add dropout and BatchNorm. Use a larger batch size — larger batches reduce gradient noise, helping Adam find flatter minima. Or switch to SGD+momentum with cosine lr schedule for tasks where generalisation is critical.

What this looks like at work

The optimiser line almost nobody rewrites — and the schedule bug that does real damage

Open the training script for almost any production deep learning project — a HuggingFace Trainer config, a PyTorch Lightning module, an internal training framework at a mid-size company — and the optimiser line is nearly always the same: AdamW, a learning rate somewhere between 1e-5 and 1e-3 depending on whether the run is pretraining or fine-tuning, weight_decay around 0.01, paired with a warmup-then-decay schedule. This is not laziness. AdamW's adaptive, per-weight step sizes make it forgiving of imperfect learning rate choices across a huge range of architectures and datasets, so teams standardise on it and spend their tuning effort on data, architecture, and regularisation instead of relitigating the optimiser choice on every new project.

SGD with momentum still gets deliberately chosen in a narrower set of cases — mainly teams training large vision backbones from scratch on ImageNet-scale data, where the flatter minima it tends to find translate into a real generalisation advantage worth the slower convergence and extra learning-rate tuning. Outside of large-scale vision pretraining, reaching for SGD instead of AdamW as a default is unusual enough that it should have a specific reason behind it, not habit.

A real debugging scenario — the plateau that was not a capacity ceiling

A team training a fraud model watches validation loss improve steadily for twenty-five epochs, then go completely flat for the next fifteen. The instinctive read is "the model has hit its capacity — time to add layers or more features." Before touching the architecture, a more experienced engineer asks a cheaper question first: what did the learning rate actually do during those fifteen flat epochs?

The scheduler was a StepLR configured to halve the learning rate every ten epochs — but scheduler.step() had been placed inside the batch loop instead of the epoch loop. With roughly two hundred batches per epoch, ten "steps" of decay happened before the first epoch even finished. By epoch fifteen the effective learning rate had been halved dozens of times over and was effectively zero. The model had not run out of capacity — it had simply stopped being allowed to move.

python
import torch
import torch.nn as nn
import torch.optim as optim

# ── The bug — scheduler.step() one indentation level too deep ─────────
model     = nn.Linear(10, 1)
optimizer = optim.AdamW(model.parameters(), lr=1e-3)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

# WRONG: called once per batch — decays far faster than intended
def train_epoch_buggy(loader):
    for Xb, yb in loader:
        optimizer.zero_grad()
        loss = nn.functional.mse_loss(model(Xb), yb)
        loss.backward()
        optimizer.step()
        scheduler.step()          # ← bug: inside the batch loop

# CORRECT: called once per epoch
def train_epoch_fixed(loader):
    for Xb, yb in loader:
        optimizer.zero_grad()
        loss = nn.functional.mse_loss(model(Xb), yb)
        loss.backward()
        optimizer.step()
    scheduler.step()              # ← fix: after the loader is exhausted

# ── The diagnostic that catches this class of bug in general ──────────
# Log the actual learning rate every epoch — not just the loss.
# A schedule bug is invisible in the loss curve until it is severe;
# it is immediately visible in the learning-rate curve.
def log_lr(optimizer, epoch):
    current_lr = optimizer.param_groups[0]['lr']
    print(f"  epoch {epoch:3d}: lr = {current_lr:.8f}")

print("Always plot or log optimizer.param_groups[0]['lr'] per epoch.")
print("A plateau that lines up with the learning rate collapsing to")
print("near-zero is a scheduler bug, not a capacity problem — and it")
print("costs nothing to check before reaching for a bigger model.")
Misconceptions

Five things people get wrong about optimisers

Myth: Adam converges faster than SGD, so there is no real downside to always using it

Faster convergence and better final performance are different properties, and this module's own comparison section shows them diverging: on large-scale image classification and some NLP tasks, SGD with momentum reaches worse training loss more slowly but ends up generalising better, because Adam's adaptive per-weight steps tend to settle into sharp, narrow minima while SGD's noisier trajectory tends to find flatter ones. "Always use Adam" is a reasonable starting default, not a universal law — the right answer depends on the architecture and how much the task rewards generalisation over raw convergence speed.

Myth: Momentum in SGD and the 'moment' terms in Adam are basically the same idea

They share a name and half a mechanism, not the whole idea. SGD's momentum is only a first moment — an exponential moving average of the gradient direction — used to smooth out noise and build speed in consistent directions, but every weight still gets the same learning rate. Adam adds a second moment — an exponential moving average of the squared gradient — and divides the step by its square root, giving each individual weight its own adaptive effective learning rate based on how large its gradients have historically been. Momentum changes the direction of the step; Adam's second moment changes the size of the step, per parameter, independently of direction.

Myth: Weight decay and L2 regularisation are just two names for the same thing, regardless of optimiser

They are mathematically equivalent under plain SGD, which is exactly why the terms get used interchangeably — but that equivalence breaks under Adam. Adding an L2 penalty to the loss in Adam means the resulting gradient term λW gets divided by the same adaptive √v̂ as every other gradient, so weights with a history of large gradients receive weaker effective decay than weights with small gradients — an uncontrolled, per-weight regularisation strength nobody actually intended. AdamW exists specifically to restore the "same fraction shrinks every weight equally" behaviour that L2 and weight decay share under plain SGD but silently lose under Adam.

Myth: If training feels slow, raising the learning rate is a safe way to speed it up

Past a certain threshold, a higher learning rate does not train faster — it stops training at all. As this module's errors section shows, too-large a step causes updates to overshoot the minimum and bounce between opposite sides of the loss valley, producing a loss that oscillates indefinitely rather than converging, sometimes even diverging to NaN. The actual fix for "training feels slow" is usually a schedule — a higher rate early for fast exploration, decayed down for precise convergence later — not a single higher static value applied for the whole run.

Myth: Learning rate warmup and schedules are optional polish you can skip when getting started

For Adam-family optimisers specifically, warmup addresses a real instability, not a refinement. In the first few steps the second moment estimate v is still close to zero, so its bias-corrected version v̂ is small, which makes the effective step size lr/√v̂ very large right when the network's weights are least trained and most sensitive to a bad update. Warmup starts the learning rate near zero and ramps it up over the first steps specifically to avoid this early-training instability — skipping it is a common, hard-to-diagnose cause of runs that diverge or perform erratically in exactly the first few hundred steps.

Interview prep

Optimisers — 5 questions interviewers actually ask

Q1 — Walk through the difference between SGD, SGD with momentum, and Adam — what problem does each addition solve?

Plain SGD subtracts learning_rate × gradient every step — simple, but every weight gets the same step size, and noisy mini-batch gradients cause the update direction to wobble. Momentum adds an exponential moving average of past gradients (velocity) and updates from that instead of the raw gradient — this smooths out the noise and builds speed in consistent directions, addressing the wobble problem. Adam goes further by also tracking a second moment — a moving average of squared gradients — and dividing the step by its square root, so each individual weight gets its own adaptive effective learning rate: large, frequently-updated gradients get smaller steps, and rare, small gradients get larger ones.

Q2 — Why was AdamW introduced when Adam already had a weight_decay parameter?

Because in Adam, weight_decay is implemented as an L2 penalty folded directly into the gradient before the adaptive division by √v̂ — which means the "decay" ends up scaled unevenly per weight, exactly like any other gradient term, rather than shrinking every weight by the same intended fraction. AdamW decouples the two steps: it applies weight decay directly to the weight (W = W − lr×λ×W) separately from the adaptive gradient update, restoring the property that every weight decays by the same proportion regardless of its gradient history. This was identified by Loshchilov and Hutter in 2019, and AdamW has since replaced Adam as the practical default almost everywhere.

Q3 — What is bias correction in Adam, and why is it only needed in the early steps of training?

Adam's moment estimates m and v are both initialised to zero and updated as exponential moving averages, which means in the very first steps they are biased heavily toward zero — far below the true gradient magnitude, since there has not been enough history yet to average over. Bias correction divides each estimate by (1 − βᵗ), a factor that is small early on (correcting the underestimate strongly) and approaches 1 as t grows, so the correction fades out naturally once enough steps have accumulated real history. Without it, the first updates would be artificially tiny, slowing down early training for no good reason.

Q4 — Why might a model trained with SGD+momentum generalise better than one trained with Adam, even though Adam converges faster?

This is an empirically observed property of the loss landscape, not a bug in Adam. Adam's per-weight adaptive steps tend to converge into sharp minima — narrow valleys where the training loss is very low but small perturbations to the weights (which happen naturally when the data distribution shifts even slightly at test time) cause the loss to spike. SGD's noisier, non-adaptive trajectory tends to settle into flatter minima, which are more robust to those perturbations and therefore generalise better on unseen data. This is especially pronounced on large-scale image classification, which is why ImageNet-scale CNNs are still frequently trained with SGD+momentum rather than Adam.

Q5 — Why is warmup particularly important for Adam-family optimisers, and how does it interact with bias correction?

Bias correction fixes the fact that m and v start at zero, but it does not fix the fact that v itself is estimated from only a handful of gradient samples in the first few steps — a small, noisy sample of squared gradients can make v̂ artificially small, which makes the effective step size lr/√v̂ artificially large right when the network is least trained and most vulnerable to a bad update. Warmup adds a second, independent safeguard on top of bias correction: it explicitly scales the learning rate from near-zero up to its target value over the first several hundred steps, giving the second-moment estimate time to stabilise before the optimiser is allowed to take full-sized steps.

What comes next

Optimisers are chosen. Next: make deep networks stable and prevent them from overfitting.

You now have the complete training loop: forward pass, loss, backprop, optimiser step. Module 45 adds the two techniques that make deep networks stable and generalisable at scale — Batch Normalisation (stabilise activations between layers) and Dropout (prevent co-adaptation and overfitting). These are not optional extras — they are standard components of every production deep learning model.

Next — Module 45 · Deep Learning
Batch Normalisation and Dropout

Internal covariate shift, running statistics, and why model.eval() is not optional when BatchNorm is in your network.

Start →

🎯 Key Takeaways

  • SGD updates every weight by the same learning rate times the gradient. SGD with momentum accumulates a velocity — a weighted average of past gradients. Momentum smooths noisy gradient directions and accelerates in consistent directions. β=0.9 is the standard default.
  • Adam maintains per-weight adaptive learning rates using two moment estimates: the first moment (running mean of gradients — like momentum) and the second moment (running mean of squared gradients — measures gradient magnitude). Weights with large past gradients get smaller effective steps automatically.
  • Bias correction in Adam is essential in the first training steps. Without it, m and v start at zero and underestimate the true moments — producing unstable first updates. The correction terms 1/(1−β₁ᵗ) and 1/(1−β₂ᵗ) fix this and become negligible after ~100 steps.
  • AdamW decouples weight decay from the gradient update. In Adam, L2 regularisation is scaled by the adaptive learning rate — making it weaker for frequently-updated weights. AdamW applies weight decay directly to the weight before the gradient step — uniform across all weights. Always prefer AdamW over Adam.
  • Default starting point for any new deep learning project: AdamW with lr=1e-3 and weight_decay=0.01. Pair with CosineAnnealingLR or ReduceLROnPlateau. Only switch to SGD+momentum when you have evidence it generalises better — primarily large-scale image classification.
  • The mandatory training step order: optimizer.zero_grad() → forward pass → loss → loss.backward() → optimizer.step(). Never rearrange these four lines. zero_grad() must come before backward() — PyTorch accumulates gradients by default and calling zero_grad() after backward() clears the gradients before they are used.
Share

Discussion

0

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

Continue with GitHub
Loading...