AdamW › Implementing AdamW from Scratch

Michael BrenndoerferApril 20, 20256 min read
Language AI HandbookAll chapters
Part 10 of 67Neural Network Foundations
Chapter 69AdamW › Implementing AdamW from Scratch
Reading section 7 of 15 · Implementing AdamW from Scratch · 6 minSection 7 of 15 · 6 min

Book contents

Section 7 of 15 · 6 min read

AdamW › Implementing AdamW from ScratchOne section · Sections in this chapter7 / 15 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement both Adam with L2 regularization and AdamW from scratch to see the difference in practice. We'll train a small regression network on noisy data and compare how the two approaches handle regularization.

In[3]:
Code
import numpy as np

# Reproducibility
np.random.seed(42)

# Generate noisy regression data
n_samples = 200
X = np.linspace(-3, 3, n_samples).reshape(-1, 1)
y_true = (
    0.5 * X.squeeze() ** 3 - 2 * X.squeeze() + np.random.randn(n_samples) * 1.5
)

# Normalize
X_mean, X_std = X.mean(), X.std()
y_mean, y_std = y_true.mean(), y_true.std()
X_norm = (X - X_mean) / X_std
y_norm = (y_true - y_mean) / y_std
In[4]:
Code
# Simple 2-layer MLP with NumPy
def relu(x):
    return np.maximum(0, x)


def relu_grad(x):
    return (x > 0).astype(float)


class MLP:
    def __init__(self, input_dim, hidden_dim, output_dim, seed=0):
        rng = np.random.RandomState(seed)
        scale = 0.1
        self.W1 = rng.randn(input_dim, hidden_dim) * scale
        self.b1 = np.zeros(hidden_dim)
        self.W2 = rng.randn(hidden_dim, output_dim) * scale
        self.b2 = np.zeros(output_dim)
        self.params = [self.W1, self.b1, self.W2, self.b2]
        self.grads = [None] * 4

    def forward(self, X):
        self.h1_pre = X @ self.W1 + self.b1
        self.h1 = relu(self.h1_pre)
        self.out = self.h1 @ self.W2 + self.b2
        return self.out.squeeze()

    def backward(self, X, y_pred, y_true):
        n = len(y_true)
        dout = 2 * (y_pred - y_true) / n
        dW2 = self.h1.T @ dout.reshape(-1, 1)
        db2 = dout.sum(keepdims=True)
        dh1 = dout.reshape(-1, 1) @ self.W2.T
        dh1_pre = dh1 * relu_grad(self.h1_pre)
        dW1 = X.T @ dh1_pre
        db1 = dh1_pre.sum(axis=0)
        self.grads = [dW1, db1, dW2, db2.squeeze()]
In[5]:
Code
def adam_l2_update(
    params,
    grads,
    moments,
    step,
    lr=1e-3,
    beta1=0.9,
    beta2=0.999,
    eps=1e-8,
    l2_lambda=0.01,
):
    """Adam with L2 regularization: adds lambda*param to gradient before moment updates."""
    ms, vs = moments
    updated = []
    for i, (p, g, m, v) in enumerate(zip(params, grads, ms, vs)):
        # L2 regularization: add to gradient BEFORE updating moments
        g_reg = g + l2_lambda * p
        m_new = beta1 * m + (1 - beta1) * g_reg
        v_new = beta2 * v + (1 - beta2) * g_reg**2
        m_hat = m_new / (1 - beta1**step)
        v_hat = v_new / (1 - beta2**step)
        p_new = p - lr * m_hat / (np.sqrt(v_hat) + eps)
        ms[i] = m_new
        vs[i] = v_new
        updated.append(p_new)
    return updated


def adamw_update(
    params,
    grads,
    moments,
    step,
    lr=1e-3,
    beta1=0.9,
    beta2=0.999,
    eps=1e-8,
    weight_decay=0.01,
):
    """AdamW: weight decay applied AFTER moment-based update, not inside gradient."""
    ms, vs = moments
    updated = []
    for i, (p, g, m, v) in enumerate(zip(params, grads, ms, vs)):
        # Moments computed from original gradient only
        m_new = beta1 * m + (1 - beta1) * g
        v_new = beta2 * v + (1 - beta2) * g**2
        m_hat = m_new / (1 - beta1**step)
        v_hat = v_new / (1 - beta2**step)
        # Weight decay applied separately, outside adaptive scaling
        p_new = p - lr * (m_hat / (np.sqrt(v_hat) + eps) + weight_decay * p)
        ms[i] = m_new
        vs[i] = v_new
        updated.append(p_new)
    return updated

The key difference is in a single line. In adam_l2_update, the regularization is added to the gradient before it enters the moment estimates (g_reg = g + l2_lambda * p). In adamw_update, the moments are computed from the original gradient, and the weight decay is added as a completely separate term outside the adaptive scaling. The regularization term never sees the adaptive denominator v^t+ϵ\sqrt{\hat{v}_t} + \epsilon.

In[6]:
Code
def train_with_norms(
    optimizer_fn,
    n_epochs=500,
    lr=1e-2,
    reg=0.01,
    seed=0,
    use_weight_decay=False,
):
    """Train the MLP and track weight norms at each epoch."""
    model = MLP(1, 32, 1, seed=seed)
    ms = [np.zeros_like(p) for p in model.params]
    vs = [np.zeros_like(p) for p in model.params]
    moments = [ms, vs]
    losses = []
    weight_norms = []

    for epoch in range(1, n_epochs + 1):
        y_pred = model.forward(X_norm)
        loss = np.mean((y_pred - y_norm) ** 2)
        losses.append(loss)
        model.backward(X_norm, y_pred, y_norm)
        if use_weight_decay:
            new_params = optimizer_fn(
                model.params,
                model.grads,
                moments,
                epoch,
                lr=lr,
                weight_decay=reg,
            )
        else:
            new_params = optimizer_fn(
                model.params, model.grads, moments, epoch, lr=lr, l2_lambda=reg
            )
        model.W1, model.b1, model.W2, model.b2 = new_params
        model.params = new_params
        total_norm = sum(np.sum(p**2) for p in model.params)
        weight_norms.append(np.sqrt(total_norm))

    return losses, weight_norms, model


losses_adam_l2, norms_adam_l2, model_adam = train_with_norms(
    adam_l2_update, lr=1e-2, reg=0.05, use_weight_decay=False
)
losses_adamw, norms_adamw, model_adamw = train_with_norms(
    adamw_update, lr=1e-2, reg=0.05, use_weight_decay=True
)
Out[7]:
Console
Adam + L2  final loss: 0.3916
AdamW      final loss: 0.2395
Adam + L2  final weight norm: 3.1271
AdamW      final weight norm: 5.3782

The final loss values tell only part of the story. The more interesting observation is in the weight norms: AdamW typically achieves a lower and more stable final weight norm than Adam+L2 with the same regularization coefficient, because the weight decay is applied uniformly rather than being mediated by gradient history.

Out[8]:
Visualization
Line plot showing MSE training loss curves for Adam L2 and AdamW over 500 epochs.
Training loss over 500 epochs for Adam with L2 regularization versus AdamW, both using a regularization coefficient of 0.05. Both optimizers converge to similar final loss values on this small regression task, showing that the choice between them does not dramatically affect training accuracy when data is plentiful.
Line plot showing weight norm evolution for Adam L2 and AdamW over 500 epochs.
Weight norm trajectory over 500 epochs for Adam+L2 versus AdamW. AdamW consistently reaches and maintains a lower weight norm, which reflects the uniform decoupled weight decay that operates independently of each parameter's gradient history.

The weight norm comparison reveals a key behavioral difference. AdamW achieves more controlled weight norms because the decay is applied uniformly without interference from the adaptive learning rate scaling. Adam+L2's weight norms are less predictable because the effective regularization strength varies by parameter, and the optimizer is not actually minimizing the same objective you think it is.