Article / Deep Learning

Backpropagation through convolutional and recurrent networks

Derive and numerically verify gradients for convolution, pooling, and recurrent networks, then see why gradients vanish or explode over time and how LSTMs help.

Aayush Raj Regmi20 min read
In this article · 11 sections

Backpropagation is one idea, the chain rule applied in reverse over a computation graph, but it looks different depending on how a layer reuses its parameters. A convolutional layer applies the same small kernel at every spatial position. A recurrent layer applies the same weight matrix at every time step. In both cases a single parameter influences the loss through many paths, and its gradient is the sum over all of them. Most of what is special about CNN and RNN backpropagation follows from that one observation.

This guide derives the gradients for convolution, pooling, and a vanilla recurrent network by hand, checks them with small NumPy implementations and finite differences, and then uses the recurrent derivation to explain why gradients vanish or explode over long sequences and how LSTMs change that picture. It assumes you are comfortable with backpropagation in a multilayer perceptron.

A short recap: deltas flow backward

The backpropagation guide covers the fully connected case in detail. The one idea we need is the delta recursion. For a dense layer z=Wa+bz=Wa+b with upstream gradient δ=∂L/∂z\delta=\partial L/\partial z, the local rules are:

∂L∂W=δ a⊤,∂L∂b=δ,∂L∂a=W⊤δ.\frac{\partial L}{\partial W}=\delta\,a^\top,\qquad \frac{\partial L}{\partial b}=\delta,\qquad \frac{\partial L}{\partial a}=W^\top\delta.

The pattern is general. Every layer receives the gradient of the loss with respect to its output, multiplies by its local Jacobian to produce the gradient with respect to its input, and accumulates gradients for its own parameters. When a variable feeds several downstream computations, the multivariable chain rule adds their contributions:

∂L∂v=∑j∈children⁡(v)∂L∂uj ∂uj∂v.\frac{\partial L}{\partial v}=\sum_{j\in\operatorname{children}(v)}\frac{\partial L}{\partial u_j}\,\frac{\partial u_j}{\partial v}.

Convolutions and recurrences are nothing more than graphs in which certain variables, the kernel weights or the recurrent matrix, have many children. Keep that sum in mind; every formula below is an instance of it.

Convolution, cross-correlation, and output sizes

In signal processing, the discrete convolution of an image XX with a kernel KK flips the kernel before sliding it: (X∗K)i,j=∑u,vXi−u, j−vKu,v(X*K)_{i,j}=\sum_{u,v}X_{i-u,\,j-v}K_{u,v}. Deep learning libraries almost always compute cross-correlation instead, with no flip:

(X⋆K)i,j=∑u=0kH−1∑v=0kW−1Xi+u, j+v Ku,v.(X\star K)_{i,j}=\sum_{u=0}^{k_H-1}\sum_{v=0}^{k_W-1}X_{i+u,\,j+v}\,K_{u,v}.

Because the kernel is learned, the distinction does not change what a network can represent: a network trained with one convention would simply learn the flipped kernel under the other. It does matter when deriving gradients, because the backward pass for the input turns out to be a true convolution, which is a cross-correlation with a kernel rotated by 180 degrees. Throughout this article "conv" means the cross-correlation that frameworks implement.

For an input of height HH, kernel height kk, zero padding pp on each side, and stride ss, the output height is:

Hout=⌊H+2p−ks⌋+1.H_{\text{out}}=\left\lfloor\frac{H+2p-k}{s}\right\rfloor+1.

The same formula applies to width. A 32×3232\times32 input with a 5×55\times5 kernel, no padding, and stride one gives 28×2828\times28. With a 3×33\times3 kernel, padding one, and stride two it gives ⌊31/2⌋+1=16\lfloor 31/2\rfloor+1=16. When H+2p−kH+2p-k is not divisible by ss, the floor silently discards the last rows of the input; those pixels receive zero gradient. Dumoulin and Visin's guide to convolution arithmetic works through all of these cases, including transposed convolution, with diagrams.

The multi-channel forward pass

A real convolutional layer takes CC input channels and produces FF output channels, or feature maps. Its kernel tensor has shape (F,C,kH,kW)(F,C,k_H,k_W) and its bias has one entry per output channel. With stride one and an already padded input, the forward pass for one example is:

Yf,i,j=bf+∑c=1C∑u=0kH−1∑v=0kW−1Kf,c,u,v Xc, i+u, j+v.Y_{f,i,j}=b_f+\sum_{c=1}^{C}\sum_{u=0}^{k_H-1}\sum_{v=0}^{k_W-1}K_{f,c,u,v}\,X_{c,\,i+u,\,j+v}.

With stride ss the input index becomes Xc, si+u, sj+vX_{c,\,si+u,\,sj+v}. Each output channel sums over all input channels, so a 3×33\times3 kernel is really a C×3×3C\times3\times3 block of weights. A typical network stacks these layers with nonlinearities and pooling, then hands a flattened representation to dense layers, as in LeCun et al. (1998).

flowchart LR
    X["Input image<br/>3 x 32 x 32"] --> C1["Conv 3x3<br/>16 filters"]
    C1 --> R1["ReLU"]
    R1 --> P1["Max pool 2x2"]
    P1 --> C2["Conv 3x3<br/>32 filters"]
    C2 --> R2["ReLU"]
    R2 --> P2["Max pool 2x2"]
    P2 --> FL["Flatten"]
    FL --> D["Dense layer"]
    D --> L["Softmax and<br/>cross-entropy loss"]

Parameter sharing

The same kernel weights are reused at every spatial position. A 3×33\times3 convolution from 3 to 64 channels has 64⋅3⋅9+64=179264\cdot3\cdot9+64=1792 parameters, regardless of image size. A dense layer connecting a 3×32×323\times32\times32 input to a 64×32×3264\times32\times32 output would need 3072⋅65536=201,326,5923072\cdot65536=201{,}326{,}592 weights. Sharing encodes an assumption, that a useful local pattern is useful wherever it appears, and it makes the layer translation equivariant: shifting the input shifts the feature map. That assumption is also why the kernel gradient will be a sum over positions.

Receptive field growth

Each output of a k×kk\times k layer sees a k×kk\times k window of its input. Stacking layers widens the window. For layer ℓ\ell with kernel size kℓk_\ell and stride sℓs_\ell, the receptive field in input pixels grows as:

rℓ=rℓ−1+(kℓ−1)∏m=1ℓ−1sm,r0=1.r_\ell=r_{\ell-1}+(k_\ell-1)\prod_{m=1}^{\ell-1}s_m,\qquad r_0=1.

Three stride-one 3×33\times3 layers therefore see 7×77\times7 pixels, the same as one 7×77\times7 layer, but with 27C227C^2 instead of 49C249C^2 weights for CC channels in and out, and two extra nonlinearities. Strides and pooling multiply the growth, which is how deep networks reach whole-image context.

Deriving the convolution gradients

Let Gf,i,j=∂L/∂Yf,i,jG_{f,i,j}=\partial L/\partial Y_{f,i,j} be the upstream gradient. We need three things: the gradient for the kernel, the bias, and the input. Work with stride one first.

Kernel. The weight Kf,c,u,vK_{f,c,u,v} appears in every output Yf,i,jY_{f,i,j} of channel ff, multiplied by Xc,i+u,j+vX_{c,i+u,j+v}. Summing over its children:

∂L∂Kf,c,u,v=∑i,jGf,i,j Xc, i+u, j+v.\frac{\partial L}{\partial K_{f,c,u,v}}=\sum_{i,j}G_{f,i,j}\,X_{c,\,i+u,\,j+v}.

Compare this with the forward formula. It is a cross-correlation of the input channel XcX_c with the upstream gradient map GfG_f, which plays the role of the kernel. For a batch, add the same quantity over examples.

Bias. bfb_f enters every output of channel ff with coefficient one, so ∂L/∂bf=∑i,jGf,i,j\partial L/\partial b_f=\sum_{i,j}G_{f,i,j}, summed over the batch as well.

Input. An input pixel Xc,m,nX_{c,m,n} contributes to output Yf,i,jY_{f,i,j} whenever i+u=mi+u=m and j+v=nj+v=n for some valid kernel offset. Writing i=m−ui=m-u and j=n−vj=n-v:

∂L∂Xc,m,n=∑f=1F∑u=0kH−1∑v=0kW−1Gf, m−u, n−v Kf,c,u,v,\frac{\partial L}{\partial X_{c,m,n}}=\sum_{f=1}^{F}\sum_{u=0}^{k_H-1}\sum_{v=0}^{k_W-1}G_{f,\,m-u,\,n-v}\,K_{f,c,u,v},

where GG is treated as zero outside its valid range. The minus signs make this a true convolution. To see it as a cross-correlation, define the rotated kernel K~f,c,u,v=Kf,c, kH−1−u, kW−1−v\tilde K_{f,c,u,v}=K_{f,c,\,k_H-1-u,\,k_W-1-v} and pad GG with k−1k-1 zeros on every side to obtain G~\tilde G, where G~f,a,b=Gf, a−(kH−1), b−(kW−1)\tilde G_{f,a,b}=G_{f,\,a-(k_H-1),\,b-(k_W-1)}. Substituting u′=kH−1−uu'=k_H-1-u and v′=kW−1−vv'=k_W-1-v:

∂L∂Xc,m,n=∑f∑u′,v′G~f, m+u′, n+v′ K~f,c,u′,v′.\frac{\partial L}{\partial X_{c,m,n}}=\sum_{f}\sum_{u',v'}\tilde G_{f,\,m+u',\,n+v'}\,\tilde K_{f,c,u',v'}.

So the input gradient is a "full" cross-correlation of the padded upstream gradient with the 180-degree rotated kernel, summed over output channels. The full padding makes the result the same size as the input: an H−k+1H-k+1 map padded by k−1k-1 on each side has size H+k−1H+k-1, and correlating with a size-kk kernel returns HH.

flowchart TD
    X["Input X"] --> FWD["Forward: correlate X with K, add b"]
    K["Kernel K"] --> FWD
    FWD --> Y["Output Y"]
    Y --> G["Upstream gradient G = dL/dY"]
    G -.-> DK["dL/dK: correlate X with G"]
    X -.-> DK
    G -.-> DB["dL/db: sum G over positions"]
    G -.-> DX["dL/dX: full correlation of padded G<br/>with 180 degree rotated K"]
    K -.-> DX

Stride and padding. With stride ss, the kernel gradient uses Xc, si+u, sj+vX_{c,\,si+u,\,sj+v} in place of Xc,i+u,j+vX_{c,i+u,j+v}. For the input gradient, the cleanest view is to dilate GG by inserting s−1s-1 zeros between its entries, then apply the stride-one formula; this is exactly a transposed convolution. Padding is easiest to handle by computing the gradient with respect to the padded input and cropping the border away, since the padded zeros are constants. The implementation below does exactly that.

A worked numeric example

Take a single channel, a 3×33\times3 input, a 2×22\times2 kernel, and bias 0.50.5:

X=[120013211],K=[1−120].X=\begin{bmatrix}1&2&0\\0&1&3\\2&1&1\end{bmatrix},\qquad K=\begin{bmatrix}1&-1\\2&0\end{bmatrix}.

The output is 2×22\times2. For the top-left entry, Y0,0=1⋅1+2⋅(−1)+0⋅2+1⋅0+0.5=−0.5Y_{0,0}=1\cdot1+2\cdot(-1)+0\cdot2+1\cdot0+0.5=-0.5. The full output is Y=[−0.54.53.50.5]Y=\begin{bmatrix}-0.5&4.5\\3.5&0.5\end{bmatrix}. Suppose the upstream gradient is:

G=[10−12].G=\begin{bmatrix}1&0\\-1&2\end{bmatrix}.

The kernel gradient correlates XX with GG. Its top-left entry overlays GG on the top-left 2×22\times2 block of XX: 1⋅1+2⋅0+0⋅(−1)+1⋅2=31\cdot1+2\cdot0+0\cdot(-1)+1\cdot2=3. Repeating at the other three offsets gives:

∂L∂K=[3702],∂L∂b=1+0−1+2=2.\frac{\partial L}{\partial K}=\begin{bmatrix}3&7\\0&2\end{bmatrix},\qquad \frac{\partial L}{\partial b}=1+0-1+2=2.

For the input gradient, the center pixel X1,1X_{1,1} is touched by all four outputs, each through a different kernel entry: G0,0K1,1+G0,1K1,0+G1,0K0,1+G1,1K0,0=0+0+1+2=3G_{0,0}K_{1,1}+G_{0,1}K_{1,0}+G_{1,0}K_{0,1}+G_{1,1}K_{0,0}=0+0+1+2=3. The corner pixel X0,0X_{0,0} is touched only by Y0,0Y_{0,0} through K0,0K_{0,0}, so its gradient is 11. The complete result is:

∂L∂X=[1−1013−2−240].\frac{\partial L}{\partial X}=\begin{bmatrix}1&-1&0\\1&3&-2\\-2&4&0\end{bmatrix}.

The code below reproduces each number and checks the input gradient against central finite differences of L=∑i,jGi,jYi,jL=\sum_{i,j}G_{i,j}Y_{i,j}, a loss chosen so that ∂L/∂Y=G\partial L/\partial Y=G exactly.

import numpy as np

X = np.array([[1., 2., 0.],
              [0., 1., 3.],
              [2., 1., 1.]])
K = np.array([[1., -1.],
              [2.,  0.]])
b = 0.5

def corr2d(A, B):
    """Valid cross-correlation of A with B (no kernel flip)."""
    H = A.shape[0] - B.shape[0] + 1
    W = A.shape[1] - B.shape[1] + 1
    return np.array([[np.sum(A[i:i+B.shape[0], j:j+B.shape[1]] * B)
                      for j in range(W)] for i in range(H)])

Y = corr2d(X, K) + b
G = np.array([[1., 0.],          # upstream gradient dL/dY
              [-1., 2.]])

dK = corr2d(X, G)                          # correlate input with upstream grad
db = G.sum()
dX = corr2d(np.pad(G, 1), np.rot90(K, 2))  # full conv with rotated kernel

print("Y =\n", Y)
print("dK =\n", dK)
print("db =", db)
print("dX =\n", dX)

# Brute-force check: L = sum(G * Y) has dL/dY = G exactly.
eps = 1e-6
num_dX = np.zeros_like(X)
for i in range(3):
    for j in range(3):
        Xp = X.copy(); Xp[i, j] += eps
        Xm = X.copy(); Xm[i, j] -= eps
        num_dX[i, j] = (np.sum(G * corr2d(Xp, K)) - np.sum(G * corr2d(Xm, K))) / (2 * eps)
print("max |dX - numeric| =", np.abs(dX - num_dX).max())

On my run the finite-difference discrepancy was about 3×10−103\times10^{-10}, which is round-off, not a derivation error.

A multi-channel implementation and the im2col view

The naive implementation below follows the forward formula with explicit loops. Its backward pass does not use the rotated-kernel formula directly. Instead, it walks over outputs and asks which patch and which weights produced each one, then scatters the gradient back. This "scatter" form and the "full correlation" form compute the same sums in a different order; the scatter version is easier to get right and handles padding by cropping.

import numpy as np

def conv2d_forward(X, W, b, pad=0):
    """X: (N, C, H, W_in), W: (F, C, kH, kW), b: (F,). Stride 1."""
    N, C, H, Wd = X.shape
    F, _, kH, kW = W.shape
    Xp = np.pad(X, ((0, 0), (0, 0), (pad, pad), (pad, pad)))
    Ho, Wo = H + 2 * pad - kH + 1, Wd + 2 * pad - kW + 1
    Y = np.zeros((N, F, Ho, Wo))
    for n in range(N):
        for f in range(F):
            for i in range(Ho):
                for j in range(Wo):
                    Y[n, f, i, j] = np.sum(Xp[n, :, i:i+kH, j:j+kW] * W[f]) + b[f]
    return Y, (X, W, pad)

def conv2d_backward(dY, cache):
    X, W, pad = cache
    N, C, H, Wd = X.shape
    F, _, kH, kW = W.shape
    _, _, Ho, Wo = dY.shape
    Xp = np.pad(X, ((0, 0), (0, 0), (pad, pad), (pad, pad)))
    dXp = np.zeros_like(Xp)
    dW = np.zeros_like(W)
    db = dY.sum(axis=(0, 2, 3))
    for n in range(N):
        for f in range(F):
            for i in range(Ho):
                for j in range(Wo):
                    g = dY[n, f, i, j]
                    # Y[n,f,i,j] touched this patch with these weights:
                    dW[f] += g * Xp[n, :, i:i+kH, j:j+kW]
                    dXp[n, :, i:i+kH, j:j+kW] += g * W[f]
    dX = dXp[:, :, pad:pad+H, pad:pad+Wd] if pad else dXp
    return dX, dW, db

def num_grad(f, A, eps=1e-6):
    g = np.zeros_like(A)
    it = np.nditer(A, flags=["multi_index"])
    for _ in it:
        idx = it.multi_index
        old = A[idx]
        A[idx] = old + eps; fp = f()
        A[idx] = old - eps; fm = f()
        A[idx] = old
        g[idx] = (fp - fm) / (2 * eps)
    return g

def rel_err(a, b):
    return np.abs(a - b).max() / max(1e-12, np.abs(a).max() + np.abs(b).max())

rng = np.random.default_rng(0)
X = rng.standard_normal((2, 3, 5, 5))
W = rng.standard_normal((4, 3, 3, 3))
b = rng.standard_normal(4)
R = rng.standard_normal((2, 4, 5, 5))   # random projection makes L a scalar

def loss():
    Y, _ = conv2d_forward(X, W, b, pad=1)
    return np.sum(Y * R)                 # so dL/dY = R

Y, cache = conv2d_forward(X, W, b, pad=1)
dX, dW, db = conv2d_backward(R, cache)
print("dX rel err:", rel_err(dX, num_grad(loss, X)))
print("dW rel err:", rel_err(dW, num_grad(loss, W)))
print("db rel err:", rel_err(db, num_grad(loss, b)))

All three relative errors were below 10−910^{-9} in my run. The random projection RR is a useful trick: a loss of the form ∑Y⊙R\sum Y\odot R exercises every output with a different weight, so an index error cannot hide behind symmetric upstream gradients.

Convolution as a matrix multiply

The im2col transformation copies every receptive-field patch into a column of a matrix AA with CkHkWCk_Hk_W rows and one column per output position. Reshape the kernel into a matrix WmW_m of shape (F, CkHkW)(F,\,Ck_Hk_W). The entire convolution becomes Ym=WmAY_m=W_mA, a dense layer applied to patches. The dense-layer rules from the recap carry over immediately:

∂L∂Wm=GmA⊤,∂L∂A=Wm⊤Gm.\frac{\partial L}{\partial W_m}=G_m A^\top,\qquad \frac{\partial L}{\partial A}=W_m^\top G_m.

The only new step is col2im, which adds each column of ∂L/∂A\partial L/\partial A back into its patch. Because neighboring patches overlap, a pixel's gradient is the sum of contributions from every patch that contained it, which is the same sum as before.

import numpy as np

def im2col(X, kH, kW):
    """X: (C, H, W) -> columns of shape (C*kH*kW, Ho*Wo), stride 1, no padding."""
    C, H, W = X.shape
    Ho, Wo = H - kH + 1, W - kW + 1
    cols = np.empty((C * kH * kW, Ho * Wo))
    for i in range(Ho):
        for j in range(Wo):
            cols[:, i * Wo + j] = X[:, i:i+kH, j:j+kW].ravel()
    return cols, Ho, Wo

def col2im(dcols, shape, kH, kW):
    """Adjoint of im2col: scatter-add every column back into its patch."""
    C, H, W = shape
    Ho, Wo = H - kH + 1, W - kW + 1
    dX = np.zeros(shape)
    for i in range(Ho):
        for j in range(Wo):
            dX[:, i:i+kH, j:j+kW] += dcols[:, i * Wo + j].reshape(C, kH, kW)
    return dX

rng = np.random.default_rng(1)
C, H, Wd, F, k = 2, 4, 4, 3, 3
X = rng.standard_normal((C, H, Wd))
Wt = rng.standard_normal((F, C, k, k))

cols, Ho, Wo = im2col(X, k, k)
Wm = Wt.reshape(F, -1)                  # (F, C*k*k)
Y = (Wm @ cols).reshape(F, Ho, Wo)      # convolution as one matmul

# Reference: direct loops.
Yref = np.array([[[np.sum(X[:, i:i+k, j:j+k] * Wt[f]) for j in range(Wo)]
                  for i in range(Ho)] for f in range(F)])
print("forward match:", np.allclose(Y, Yref))

# Backward is the dense-layer rule Y = W A  =>  dW = dY A^T,  dA = W^T dY.
dY = rng.standard_normal((F, Ho, Wo))
dYm = dY.reshape(F, -1)
dW = (dYm @ cols.T).reshape(Wt.shape)
dX = col2im(Wm.T @ dYm, X.shape, k, k)

# Same answers as the loop-based rule.
dW_ref = np.zeros_like(Wt); dX_ref = np.zeros_like(X)
for f in range(F):
    for i in range(Ho):
        for j in range(Wo):
            dW_ref[f] += dY[f, i, j] * X[:, i:i+k, j:j+k]
            dX_ref[:, i:i+k, j:j+k] += dY[f, i, j] * Wt[f]
print("dW match:", np.allclose(dW, dW_ref), " dX match:", np.allclose(dX, dX_ref))

Many production libraries have used im2col-style lowering because large matrix multiplies are extremely well optimized, though modern kernels also use direct, FFT, and Winograd algorithms. The memory cost is visible in the shapes: each input pixel is copied up to kHkWk_Hk_W times.

Pooling layers route the gradient

Pooling has no parameters, so its backward pass only has to answer one question: how much did each input change the output? For max pooling over a window, the output equals the largest input. Infinitesimally perturbing a non-maximal input does not change the output, so its derivative is zero. Perturbing the maximal input changes the output one for one:

∂Yi,j∂Xm,n={1if (m,n)=argmax⁡ of window (i,j),0otherwise.\frac{\partial Y_{i,j}}{\partial X_{m,n}}=\begin{cases}1&\text{if }(m,n)=\operatorname{argmax}\text{ of window }(i,j),\\0&\text{otherwise.}\end{cases}

The upstream gradient is routed to the winner and the other inputs receive nothing. The forward pass must therefore remember where each maximum was. Average pooling over an s×ss\times s window has Y=1s2∑XY=\frac{1}{s^2}\sum X, so each input receives an equal share Gi,j/s2G_{i,j}/s^2.

import numpy as np

def maxpool_forward(X, s=2):
    """X: (H, W) with H, W divisible by s. Non-overlapping s x s windows."""
    H, W = X.shape
    blocks = X.reshape(H // s, s, W // s, s)
    Y = blocks.max(axis=(1, 3))
    mask = blocks == Y[:, None, :, None]   # where each max came from
    return Y, mask

def maxpool_backward(dY, mask):
    # Route each upstream gradient to its argmax (ties would all receive it;
    # frameworks usually pick one).
    s = mask.shape[1]
    d = mask * dY[:, None, :, None]
    return d.reshape(mask.shape[0] * s, mask.shape[2] * s)

def avgpool_backward(dY, s=2):
    # Every input in the window gets an equal 1/s^2 share.
    return np.kron(dY, np.ones((s, s))) / (s * s)

X = np.array([[1., 3., 2., 0.],
              [4., 2., 1., 5.],
              [0., 1., 2., 2.],
              [3., 0., 1., 7.]])
Y, mask = maxpool_forward(X)
dY = np.array([[10., 20.],
               [30., 40.]])
print("Y =\n", Y)
print("max-pool dX =\n", maxpool_backward(dY, mask))
print("avg-pool dX =\n", avgpool_backward(dY))

The max-pooled output is [4537]\begin{bmatrix}4&5\\3&7\end{bmatrix}, and the gradients 10,20,30,4010,20,30,40 land exactly at the positions of 4,5,3,74,5,3,7. Max is not differentiable at ties, where any convex combination of the tied subgradients is valid; with real-valued activations exact ties are uncommon, but they do occur after ReLU produces many zeros. The comment in the code marks that edge case.

Recurrent networks and backpropagation through time

A vanilla recurrent network reads a sequence x1,…,xTx_1,\ldots,x_T and keeps a hidden state:

at=Whhht−1+Wxhxt+b,ht=tanh⁡(at),yt=Whyht+c.a_t=W_{hh}h_{t-1}+W_{xh}x_t+b,\qquad h_t=\tanh(a_t),\qquad y_t=W_{hy}h_t+c.

The loss is a sum of per-step terms, L=∑t=1TLt(yt)L=\sum_{t=1}^{T}L_t(y_t), with h0h_0 given. Unrolling the loop over time produces an ordinary deep feedforward graph with one layer per time step, in which every layer shares the same weights. Backpropagation on that graph is called backpropagation through time, a name popularized by Werbos (1990).

flowchart LR
    H0["h0"] --> H1["h1"]
    H1 --> H2["h2"]
    H2 --> H3["h3"]
    X1["x1"] --> H1
    X2["x2"] --> H2
    X3["x3"] --> H3
    H1 --> L1["Loss L1"]
    H2 --> L2["Loss L2"]
    H3 --> L3["Loss L3"]
    L3 -.-> H3
    L2 -.-> H2
    L1 -.-> H1
    H3 -.-> H2
    H2 -.-> H1

The recursion for the hidden-state gradient

Define δt=∂L/∂ht\delta_t=\partial L/\partial h_t, the gradient of the whole loss with respect to the hidden state. The state hth_t has two children: the output yty_t, and the next pre-activation at+1a_{t+1}. The chain rule adds both paths:

δt=Why⊤∂Lt∂yt+(∂ht+1∂ht) ⁣⊤δt+1,δT+1=0.\delta_t=W_{hy}^\top\frac{\partial L_t}{\partial y_t}+\left(\frac{\partial h_{t+1}}{\partial h_t}\right)^{\!\top}\delta_{t+1},\qquad \delta_{T+1}=0.

The Jacobian of one step is ∂ht+1/∂ht=diag⁡(1−ht+1⊙ht+1) Whh\partial h_{t+1}/\partial h_t=\operatorname{diag}(1-h_{t+1}\odot h_{t+1})\,W_{hh}, because tanh⁡′(a)=1−tanh⁡2(a)\tanh'(a)=1-\tanh^2(a). It is convenient to name the gradient with respect to the pre-activation:

gt=∂L∂at=δt⊙(1−ht⊙ht),δt=Why⊤∂Lt∂yt+Whh⊤gt+1.g_t=\frac{\partial L}{\partial a_t}=\delta_t\odot(1-h_t\odot h_t),\qquad \delta_t=W_{hy}^\top\frac{\partial L_t}{\partial y_t}+W_{hh}^\top g_{t+1}.

So the backward pass runs from t=Tt=T down to 11, carrying Whh⊤gt+1W_{hh}^\top g_{t+1} from the future and adding the local output gradient.

Summing over time

Each weight matrix is used at every step, so its gradient is the sum of the dense-layer rule applied at each step:

∂L∂Whh=∑t=1Tgt ht−1⊤,∂L∂Wxh=∑t=1Tgt xt⊤,∂L∂b=∑t=1Tgt,\frac{\partial L}{\partial W_{hh}}=\sum_{t=1}^{T}g_t\,h_{t-1}^\top,\qquad \frac{\partial L}{\partial W_{xh}}=\sum_{t=1}^{T}g_t\,x_t^\top,\qquad \frac{\partial L}{\partial b}=\sum_{t=1}^{T}g_t,

and similarly ∂L/∂Why=∑t(∂Lt/∂yt)ht⊤\partial L/\partial W_{hy}=\sum_t(\partial L_t/\partial y_t)h_t^\top. This is the recurrent analogue of the kernel gradient summing over spatial positions.

Implementation and gradient check

The code uses a squared-error loss at every step to keep the output layer simple. With softmax and cross-entropy, only the line computing dy would change.

import numpy as np

def rnn_forward(xs, h0, P):
    """xs: (T, D). Returns per-step hidden states, outputs, and loss."""
    Wxh, Whh, bh, Why, by = P["Wxh"], P["Whh"], P["bh"], P["Why"], P["by"]
    hs, ys = {-1: h0}, {}
    for t in range(len(xs)):
        hs[t] = np.tanh(Wxh @ xs[t] + Whh @ hs[t - 1] + bh)
        ys[t] = Why @ hs[t] + by
    return hs, ys

def rnn_loss(xs, targets, h0, P):
    _, ys = rnn_forward(xs, h0, P)
    return sum(0.5 * np.sum((ys[t] - targets[t]) ** 2) for t in range(len(xs)))

def rnn_bptt(xs, targets, h0, P):
    hs, ys = rnn_forward(xs, h0, P)
    grads = {k: np.zeros_like(v) for k, v in P.items()}
    dh_next = np.zeros_like(h0)              # dL/dh_t arriving from step t+1
    for t in reversed(range(len(xs))):
        dy = ys[t] - targets[t]              # dL_t/dy_t for squared error
        grads["Why"] += np.outer(dy, hs[t])
        grads["by"] += dy
        dh = P["Why"].T @ dy + dh_next       # two paths: output at t, and h_{t+1}
        da = dh * (1.0 - hs[t] ** 2)         # through tanh
        grads["Wxh"] += np.outer(da, xs[t])  # shared weights: sum over time
        grads["Whh"] += np.outer(da, hs[t - 1])
        grads["bh"] += da
        dh_next = P["Whh"].T @ da            # send gradient back to h_{t-1}
    return grads

rng = np.random.default_rng(0)
D, Hd, O, T = 3, 5, 2, 6
P = {"Wxh": 0.5 * rng.standard_normal((Hd, D)),
     "Whh": 0.5 * rng.standard_normal((Hd, Hd)),
     "bh": 0.1 * rng.standard_normal(Hd),
     "Why": 0.5 * rng.standard_normal((O, Hd)),
     "by": 0.1 * rng.standard_normal(O)}
xs = rng.standard_normal((T, D))
targets = rng.standard_normal((T, O))
h0 = np.zeros(Hd)

grads = rnn_bptt(xs, targets, h0, P)
eps = 1e-6
for name, A in P.items():
    num = np.zeros_like(A)
    it = np.nditer(A, flags=["multi_index"])
    for _ in it:
        idx = it.multi_index
        old = A[idx]
        A[idx] = old + eps; lp = rnn_loss(xs, targets, h0, P)
        A[idx] = old - eps; lm = rnn_loss(xs, targets, h0, P)
        A[idx] = old
        num[idx] = (lp - lm) / (2 * eps)
    err = np.abs(num - grads[name]).max() / (np.abs(num).max() + np.abs(grads[name]).max())
    print(f"{name:>3} rel err: {err:.2e}")

Every parameter's relative error was around 2×10−102\times10^{-10} to 4×10−104\times10^{-10} in my run. The dh_next variable is the whole of BPTT: delete it and the code still runs, still produces plausible gradients, and silently trains a model that ignores the past. A gradient check catches that bug immediately.

Why gradients vanish or explode

Suppose the loss depends only on the final state. Unrolling the recursion, the gradient that reaches an early state is a product of step Jacobians:

∂L∂hk=(∏t=k+1Tdiag⁡ ⁣(tanh⁡′(at)) Whh) ⁣⊤∂L∂hT.\frac{\partial L}{\partial h_k}=\left(\prod_{t=k+1}^{T}\operatorname{diag}\!\big(\tanh'(a_t)\big)\,W_{hh}\right)^{\!\top}\frac{\partial L}{\partial h_T}.

Take spectral norms. Since 0<tanh⁡′(a)≤10\lt\tanh'(a)\le1, let γ≤1\gamma\le1 bound the diagonal factors and σmax⁡\sigma_{\max} be the largest singular value of WhhW_{hh}:

∥∂hT∂hk∥2≤∏t=k+1T∥diag⁡(tanh⁡′(at))∥2 ∥Whh∥2≤(γ σmax⁡)T−k.\left\|\frac{\partial h_T}{\partial h_k}\right\|_2\le\prod_{t=k+1}^{T}\big\|\operatorname{diag}(\tanh'(a_t))\big\|_2\,\|W_{hh}\|_2\le(\gamma\,\sigma_{\max})^{T-k}.

If γσmax⁡<1\gamma\sigma_{\max}\lt1, the contribution of step kk shrinks at least exponentially with the distance T−kT-k. That is a sufficient condition for vanishing. For the linear case, decompose WhhW_{hh} in its eigenbasis: the component of the gradient along an eigenvector with eigenvalue λ\lambda is scaled by λT−k\lambda^{T-k}, so any ∣λ∣>1|\lambda|\gt1 produces exponential growth. With the nonlinearity, σmax⁡>1/γ\sigma_{\max}\gt1/\gamma is necessary for explosion but not sufficient, because saturated units shrink the diagonal factors. Bengio, Simard and Frasconi (1994) analyzed why storing information robustly in such systems tends to make long-range gradients vanish, and Pascanu, Mikolov and Bengio (2013) gave the singular-value argument above along with a geometric picture of exploding gradients.

The consequence for learning is easy to state. The total gradient for WhhW_{hh} is a sum over time of terms, and the terms linking distant steps are exponentially smaller than the terms linking nearby steps. Short-range dependencies dominate the update, so the model has little signal for learning long-range ones.

An experiment

The script below builds WhhW_{hh} as a scaled orthogonal matrix, so every singular value equals the scale. It runs 50 steps, places a unit-norm gradient on hTh_T, and prints the norm of ∂L/∂ht\partial L/\partial h_t as it travels back.

import numpy as np

def grad_norms(scale, T=50, H=32, seed=0, activation="tanh"):
    """Loss depends only on h_T. Return ||dL/dh_t|| for t = T, T-1, ..., 0."""
    rng = np.random.default_rng(seed)
    Q, _ = np.linalg.qr(rng.standard_normal((H, H)))
    Whh = scale * Q                      # every singular value equals `scale`
    Wxh = 0.25 * rng.standard_normal((H, 4))
    xs = rng.standard_normal((T, 4))
    hs = [np.zeros(H)]
    for t in range(T):
        a = Whh @ hs[-1] + Wxh @ xs[t]
        hs.append(np.tanh(a) if activation == "tanh" else a)
    g = np.ones(H) / np.sqrt(H)          # dL/dh_T, unit norm
    norms = [np.linalg.norm(g)]
    for t in range(T, 0, -1):
        d = g * (1 - hs[t] ** 2) if activation == "tanh" else g
        g = Whh.T @ d                    # dL/dh_{t-1}
        norms.append(np.linalg.norm(g))
    return norms                         # norms[k] = ||dL/dh_{T-k}||

for act, scales in [("tanh", [0.5, 1.0, 3.0]), ("linear", [0.9, 1.1])]:
    for s in scales:
        n = grad_norms(s, activation=act)
        print(f"{act:>6} scale={s:<4} steps back: 0 -> {n[0]:.2e}  "
              f"10 -> {n[10]:.2e}  25 -> {n[25]:.2e}  50 -> {n[50]:.2e}")

With this seed, the output was:

Setting10 steps back25 steps back50 steps back
tanh, scale 0.51.25e-042.08e-106.75e-20
tanh, scale 1.05.44e-028.37e-047.23e-07
tanh, scale 3.01.35e+016.06e+011.52e+04
linear, scale 0.93.49e-017.18e-025.15e-03
linear, scale 1.12.59e+001.08e+011.17e+02

The linear rows match the theory exactly: an orthogonal matrix preserves norms, so the gradient scales as 0.950≈5.15×10−30.9^{50}\approx5.15\times10^{-3} and 1.150≈1171.1^{50}\approx117. The tanh row at scale one still vanishes, because every non-zero activation makes tanh⁡′<1\tanh'\lt1, and those factors compound. At scale three the gradient grows despite partial saturation. The exact values depend on the seed and inputs; the exponential trend is the robust part.

Truncated BPTT and gradient clipping

Full BPTT over a long stream needs memory for every hidden state. Truncated BPTT splits the sequence into windows of length kk: it carries the hidden state forward across windows but stops the backward pass at the window boundary. Gradients from dependencies longer than kk steps are simply dropped, which biases the estimate. That is often acceptable because such terms were already tiny, but it also means the model is never directly trained to use information older than the window.

Exploding gradients have a blunt but effective fix. Clip by global norm: if the norm of the concatenated gradient exceeds a threshold τ\tau, rescale it.

g^=g⋅min⁡ ⁣(1,τ∥g∥2).\hat g=g\cdot\min\!\left(1,\frac{\tau}{\|g\|_2}\right).

Rescaling keeps the direction and limits the step size, which helps when the loss surface has the steep "walls" Pascanu et al. describe.

import numpy as np

def clip_by_global_norm(grads, max_norm):
    total = np.sqrt(sum(np.sum(g * g) for g in grads.values()))
    scale = min(1.0, max_norm / (total + 1e-12))
    return {k: g * scale for k, g in grads.items()}, total

grads = {"Whh": np.full((2, 2), 30.0), "Wxh": np.full((2, 1), 40.0)}
clipped, before = clip_by_global_norm(grads, max_norm=5.0)
after = np.sqrt(sum(np.sum(g * g) for g in clipped.values()))
print(f"norm before: {before:.2f}, after: {after:.2f}")

This prints a norm of 82.4682.46 before clipping and 5.005.00 after. Clipping does nothing for vanishing gradients. For that, the architecture has to change.

LSTM and GRU: an additive path for the gradient

The Long Short-Term Memory network of Hochreiter and Schmidhuber adds a cell state ctc_t whose update is mostly additive. In the widely used form with a forget gate, introduced by Gers, Schmidhuber and Cummins in 2000, write zt=[ht−1;xt]z_t=[h_{t-1};x_t] and σ\sigma for the logistic sigmoid:

it=σ(Wizt+bi),ft=σ(Wfzt+bf),ot=σ(Wozt+bo),c~t=tanh⁡(Wczt+bc),ct=ft⊙ct−1+it⊙c~t,ht=ot⊙tanh⁡(ct).\begin{aligned} i_t&=\sigma(W_i z_t+b_i), & f_t&=\sigma(W_f z_t+b_f), & o_t&=\sigma(W_o z_t+b_o),\\ \tilde c_t&=\tanh(W_c z_t+b_c), & c_t&=f_t\odot c_{t-1}+i_t\odot\tilde c_t, & h_t&=o_t\odot\tanh(c_t). \end{aligned}
flowchart LR
    CP["Previous cell c t-1"] --> MULF["Multiply by forget gate f"]
    MULF --> ADD["Add"]
    HX["Previous h and input x"] --> GF["Forget gate f sigmoid"]
    HX --> GI["Input gate i sigmoid"]
    HX --> GC["Candidate tanh"]
    HX --> GO["Output gate o sigmoid"]
    GF --> MULF
    GI --> MULI["Multiply i and candidate"]
    GC --> MULI
    MULI --> ADD
    ADD --> CN["New cell c t"]
    CN --> TH["tanh"]
    TH --> MULO["Multiply by output gate o"]
    GO --> MULO
    MULO --> HN["New hidden h t"]

The key term is the direct derivative of one cell state with respect to the previous one, ignoring the indirect paths through the gates:

∂ct∂ct−1=diag⁡(ft).\frac{\partial c_t}{\partial c_{t-1}}=\operatorname{diag}(f_t).

Along the cell path, the gradient from step TT back to step kk is multiplied by ∏t=k+1Tdiag⁡(ft)\prod_{t=k+1}^{T}\operatorname{diag}(f_t). There is no repeated multiplication by a weight matrix and no repeated tanh⁡′\tanh' factor. If the network learns forget gates near one for a particular unit, that unit's gradient can travel many steps with little decay. The gates are themselves learned, so the network decides when to keep and when to erase. Initializing the forget-gate bias to a positive value such as one is a common heuristic for starting with long memory. The full gradient also includes paths through ht−1h_{t-1} into every gate, so LSTMs reduce the vanishing problem rather than eliminate it.

The gated recurrent unit of Cho et al. (2014) merges the cell and hidden state with an update gate ztz_t and a reset gate rtr_t:

ht=(1−zt)⊙ht−1+zt⊙tanh⁡ ⁣(Wh[rt⊙ht−1;xt]+bh).h_t=(1-z_t)\odot h_{t-1}+z_t\odot\tanh\!\big(W_h[r_t\odot h_{t-1};x_t]+b_h\big).

The direct path contributes diag⁡(1−zt)\operatorname{diag}(1-z_t) to the Jacobian, which plays the same role as the forget gate. GRUs have fewer parameters per unit; neither architecture dominates the other across all tasks.

Common bugs and how to catch them

Flipping the kernel twice, or not at all. If your forward pass is cross-correlation, the input gradient needs the rotated kernel and the kernel gradient does not. Mixing conventions gives gradients that are correct for symmetric kernels and wrong otherwise. Test with an asymmetric kernel like the one in the worked example.

Overwriting instead of accumulating. Shared parameters need += across positions, examples, and time steps. Assigning with = keeps only the last contribution. The same applies to input gradients where patches overlap.

Forgetting the padding crop or stride dilation. Gradients for padded zeros must be discarded. With stride greater than one, scattering into the wrong positions often still produces the right shape.

Dropping the recurrent term. Omitting dh_next, or adding it after the tanh derivative instead of before, yields gradients that look reasonable. Only a numerical check reveals the error.

Reusing a stale hidden state across sequences. Reset or deliberately carry h0h_0 between batches. When carrying it in truncated BPTT, stop gradients at the boundary, or memory grows with every batch in autodiff frameworks.

Testing with a symmetric loss. A loss like ∑Y\sum Y gives the same upstream gradient everywhere, which can hide index transpositions. Use a random projection, as the checks above do, in double precision, with central differences and a relative error.

Practice exercises

  1. Extend conv2d_forward and conv2d_backward to stride two. Derive the kernel gradient with the index si+usi+u, implement it, and confirm it with the same finite-difference check. Then verify that the input gradient equals the stride-one formula applied to a zero-dilated GG.

  2. Compute the receptive field of a network with layers: 3×33\times3 conv stride one, 2×22\times2 max pool stride two, 3×33\times3 conv stride one, 2×22\times2 max pool stride two, 3×33\times3 conv stride one. Check your answer by placing a nonzero gradient at a single output position and counting which input pixels receive a nonzero gradient.

  3. Replace the squared-error loss in the RNN with softmax cross-entropy over a small vocabulary. Only the output gradient should change. Rerun the gradient check.

  4. Modify the vanishing-gradient experiment to track the norm of each per-step term gtht−1⊤g_t h_{t-1}^\top in the WhhW_{hh} gradient when the loss is placed on the last step. Plot or print how much of the total gradient comes from the last five steps.

  5. Implement one LSTM step forward and backward in NumPy, gradient-check it, then repeat the experiment above with forget-gate biases of −2-2, 00, and 22. Describe how the norm of ∂L/∂ct\partial L/\partial c_t changes with distance, and explain why in terms of ∏tdiag⁡(ft)\prod_t\operatorname{diag}(f_t).

Each exercise ends with a measurement. That habit, deriving a formula and then checking it numerically on a case small enough to reason about, is what makes backpropagation through any new layer routine rather than mysterious.

Sources & further reading

The explanations and worked examples in this article are original. These papers and official references support the technical background and provide further detail.

  1. Dumoulin and Visin's guide to convolution arithmetic ↗
  2. LeCun et al. (1998) ↗
  3. Werbos (1990) ↗
  4. Bengio, Simard and Frasconi (1994) ↗
  5. Pascanu, Mikolov and Bengio (2013) ↗
  6. Long Short-Term Memory ↗
Back to all writing