Overview

이번 과제에서는 CNN을 구성하는 convolution과 max pooling layer의 forward, backward를 직접 구현한다.

이어서 CNN의 activation shape인 (N,C,H,W)(N,C,H,W)에 맞춰 Spatial Batch Normalization을 구현하고, 채널을 여러 group으로 나누어 정규화하는 Spatial Group Normalization까지 구현한다.

핵심 아이디어

Convolution

입력 xx와 filter ww, bias bb의 shape은 다음과 같다.

  • 입력 xx: (N,C,H,W)(N,C,H,W)
  • filter ww: (F,C,HH,WW)(F,C,HH,WW)
  • bias bb: (F,)(F,)

NN은 batch size, CC는 입력 채널 수, FF는 filter 수이다. HHHH와 WWWW는 filter의 높이와 너비이다.

padding을 PP, stride를 SS라고 하면 출력의 공간 크기는 다음과 같다.

H′=1+⌊H+2P−HHS⌋H' = 1+\left\lfloor \frac{H+2P-HH}{S} \right\rfloor W′=1+⌊W+2P−WWS⌋W' = 1+\left\lfloor \frac{W+2P-WW}{S} \right\rfloor

따라서 출력 shape은 (N,F,H′,W′)(N,F,H',W')이다. 출력 위치 (i,j)(i,j)에서 사용하는 입력 patch를 다음과 같이 둔다.

Xn,i,jpatch=xn,:, iS:iS+HH, jS:jS+WWpadX^{\mathrm{patch}}_{n,i,j} = x^{\mathrm{pad}}_{n,:,\,iS:iS+HH,\,jS:jS+WW}

출력의 한 원소는 이 patch와 filter의 inner product에 bias를 더해서 계산한다.

On,f,i,j=⟨Xn,i,jpatch,wf⟩+bfO_{n,f,i,j} = \left\langle X^{\mathrm{patch}}_{n,i,j}, w_f \right\rangle +b_f

Convolution forward에서 입력 patch와 filter의 inner product로 출력 원소를 계산하는 과정

이번 naive convolution은 filter를 뒤집지 않고 입력 window와 같은 방향으로 곱한다.

Max Pooling

Max pooling은 각 pooling window에서 최댓값 하나를 선택한다. 채널 수는 유지하면서 공간 크기만 줄인다.

pooling window의 높이와 너비를 PHPH, PWPW, stride를 SS라고 하면 출력 크기는 다음과 같다.

H′=1+⌊H−PHS⌋H' = 1+\left\lfloor \frac{H-PH}{S} \right\rfloor W′=1+⌊W−PWS⌋W' = 1+\left\lfloor \frac{W-PW}{S} \right\rfloor

backward에서는 forward에서 최댓값이었던 위치에만 upstream gradient를 전달한다.

CNN에서의 정규화 축

Batch Normalization, Layer Normalization, Group Normalization의 정규화 축

같은 (N,C,H,W)(N,C,H,W) activation이라도 어떤 축을 묶어 평균과 분산을 계산하는지에 따라 정규화 방식이 달라진다.

방법고정하는 단위통계를 계산하는 축
Spatial BatchNorm채널 CCN,H,WN,H,W
LayerNorm샘플 NNC,H,WC,H,W
GroupNorm샘플 NN과 group GGC/G,H,WC/G,H,W

Spatial BatchNorm은 동일한 채널의 모든 batch와 공간 위치를 모아 통계를 계산한다.

μc=1NHW∑n,h,wxn,c,h,w\mu_c = \frac{1}{NHW} \sum_{n,h,w} x_{n,c,h,w}

GroupNorm은 각 샘플의 채널을 GG개 group으로 나누고, group마다 독립적으로 정규화한다. 따라서 batch size에 직접 의존하지 않는다.

구현

Convolution

Forward

def conv_forward_naive(x, w, b, conv_param):
    stride = conv_param['stride']
    pad = conv_param['pad']

    N, C, H, W = x.shape
    F, _, HH, WW = w.shape
    H_out = 1 + (H + 2 * pad - HH) // stride
    W_out = 1 + (W + 2 * pad - WW) // stride

    x_pad = np.pad(
        x,
        ((0, 0), (0, 0), (pad, pad), (pad, pad))
    )

    out = np.zeros((N, F, H_out, W_out))

    for n in range(N):
        for f in range(F):
            for i in range(H_out):
                for j in range(W_out):
                    hs = i * stride
                    ws = j * stride
                    window = x_pad[
                        n,
                        :,
                        hs:hs + HH,
                        ws:ws + WW
                    ]
                    out[n, f, i, j] = np.sum(window * w[f]) + b[f]

    cache = (x, w, b, conv_param)
    return out, cache

입력 원본을 직접 수정하지 않도록 np.pad로 새로운 배열을 만든다. 이후 각 출력 위치 (i,j)(i,j)에 대응하는 입력 window를 잘라 filter와 곱한다.

Backward

upstream gradient dout의 한 원소는 forward의 출력 한 원소에 대응한다. 이를 다음과 같이 둔다.

Gn,f,i,j=∂L∂On,f,i,jG_{n,f,i,j} = \frac{\partial L}{\partial O_{n,f,i,j}}

각 Gn,f,i,jG_{n,f,i,j}를 해당 출력 계산에 사용된 bias, filter, 입력 patch로 다시 나누어 보낸다.

Bias는 같은 filter가 만든 모든 출력 위치에서 공유되므로 전부 더한다.

∇bfL=∑n,i,jGn,f,i,j\nabla_{b_f}L = \sum_{n,i,j} G_{n,f,i,j}

Filter gradient는 각 입력 window에 그 위치의 upstream gradient를 곱한 값을 모든 batch와 공간 위치에 대해 누적한다.

∇wfL=∑n,i,jXn,i,jpatchGn,f,i,j\nabla_{w_f}L = \sum_{n,i,j} X^{\mathrm{patch}}_{n,i,j} G_{n,f,i,j}

입력 gradient는 filter에 upstream gradient를 곱해 해당 입력 window 위치에 누적한다. 여러 receptive field가 겹치는 입력 위치는 여러 출력에 영향을 주었으므로 gradient도 +=로 합쳐야 한다.

def conv_backward_naive(dout, cache):
    x, w, b, conv_param = cache
    N, C, H, W = x.shape
    F, _, HH, WW = w.shape

    stride = conv_param['stride']
    pad = conv_param['pad']

    x_pad = np.pad(
        x,
        ((0, 0), (0, 0), (pad, pad), (pad, pad))
    )
    dx_pad = np.zeros_like(x_pad)
    dw = np.zeros_like(w)
    db = np.zeros_like(b)

    H_out = 1 + (H + 2 * pad - HH) // stride
    W_out = 1 + (W + 2 * pad - WW) // stride

    for n in range(N):
        for f in range(F):
            for i in range(H_out):
                for j in range(W_out):
                    hs = i * stride
                    ws = j * stride
                    window = x_pad[
                        n,
                        :,
                        hs:hs + HH,
                        ws:ws + WW
                    ]

                    db[f] += dout[n, f, i, j]
                    dw[f] += window * dout[n, f, i, j]
                    dx_pad[
                        n,
                        :,
                        hs:hs + HH,
                        ws:ws + WW
                    ] += w[f] * dout[n, f, i, j]

    if pad == 0:
        dx = dx_pad
    else:
        dx = dx_pad[:, :, pad:-pad, pad:-pad]

    return dx, dw, db

마지막에는 forward에서 추가했던 padding을 제거하여 dx를 원래 입력과 같은 (N,C,H,W)(N,C,H,W) shape으로 복원한다. pad == 0일 때 pad:-pad를 사용하면 0:0이 되어 빈 배열이 나오므로 별도로 분기해야 한다.

Max Pooling

Forward

def max_pool_forward_naive(x, pool_param):
    ph = pool_param['pool_height']
    pw = pool_param['pool_width']
    stride = pool_param['stride']

    N, C, H, W = x.shape
    H_out = 1 + (H - ph) // stride
    W_out = 1 + (W - pw) // stride

    out = np.zeros((N, C, H_out, W_out))

    for n in range(N):
        for c in range(C):
            for i in range(H_out):
                for j in range(W_out):
                    hs = i * stride
                    ws = j * stride
                    out[n, c, i, j] = np.max(
                        x[n, c, hs:hs + ph, ws:ws + pw]
                    )

    cache = (x, pool_param)
    return out, cache

Backward

forward에서 최댓값이었던 위치를 boolean mask로 찾고, 그 위치에만 upstream gradient를 전달한다.

def max_pool_backward_naive(dout, cache):
    x, pool_param = cache

    ph = pool_param['pool_height']
    pw = pool_param['pool_width']
    stride = pool_param['stride']

    N, C, H, W = x.shape
    H_out = 1 + (H - ph) // stride
    W_out = 1 + (W - pw) // stride
    dx = np.zeros_like(x)

    for n in range(N):
        for c in range(C):
            for i in range(H_out):
                for j in range(W_out):
                    hs = i * stride
                    ws = j * stride

                    window = x[
                        n,
                        c,
                        hs:hs + ph,
                        ws:ws + pw
                    ]
                    mask = window == np.max(window)

                    dx[
                        n,
                        c,
                        hs:hs + ph,
                        ws:ws + pw
                    ] += mask * dout[n, c, i, j]

    return dx

현재 구현은 window 안에 같은 최댓값이 여러 개 있으면 모든 위치에 같은 gradient를 전달한다. 일반적인 무작위 실수 입력에서는 동점이 드물지만, 구현에 따라 첫 번째 최댓값만 선택하거나 gradient를 나누는 규칙을 사용할 수도 있다.

Spatial Batch Normalization

Spatial BatchNorm은 채널 CC를 고정하고 N,H,WN,H,W축을 합쳐 통계를 계산한다. 직접 정규화 수식을 다시 구현하지 않고, 입력을 (NHW,C)(NHW,C)로 바꿔 기존 batchnorm_forward를 재사용할 수 있다.

(N,C,H,W)→(N,H,W,C)→(NHW,C)(N,C,H,W) \rightarrow (N,H,W,C) \rightarrow (NHW,C)

Forward

def spatial_batchnorm_forward(x, gamma, beta, bn_param):
    N, C, H, W = x.shape

    x_convert = x.transpose(0, 2, 3, 1).reshape(-1, C)
    out, cache = batchnorm_forward(
        x_convert,
        gamma,
        beta,
        bn_param
    )

    out = out.reshape(N, H, W, C).transpose(0, 3, 1, 2)
    return out, cache

Backward

backward도 dout을 (NHW,C)(NHW,C)로 바꾼 뒤 기존 BatchNorm backward를 적용하고 원래 shape으로 되돌린다.

def spatial_batchnorm_backward(dout, cache):
    N, C, H, W = dout.shape

    dout_convert = dout.transpose(0, 2, 3, 1).reshape(-1, C)
    dx, dgamma, dbeta = batchnorm_backward(dout_convert, cache)
    dx = dx.reshape(N, H, W, C).transpose(0, 3, 1, 2)

    return dx, dgamma, dbeta

Spatial Group Normalization

GroupNorm은 각 샘플의 CC개 채널을 GG개 group으로 나눈다. 한 group에서 정규화하는 원소 수는 다음과 같다.

D=CGHWD = \frac{C}{G}HW

입력을 (N×G,D)(N \times G,D)로 reshape하면 각 row가 독립적으로 정규화되어야 하는 하나의 group이 된다. 이 형태는 LayerNorm의 입력과 같으므로 기존 구현을 재사용할 수 있다.

(N,C,H,W)→(NG,CGHW)(N,C,H,W) \rightarrow (NG,\frac{C}{G}HW)

Forward

def spatial_groupnorm_forward(x, gamma, beta, G, gn_param):
    N, C, H, W = x.shape

    x_group = x.reshape(N * G, -1)
    D = (C // G) * H * W

    ln_gamma = np.ones(D)
    ln_beta = np.zeros(D)

    x_norm, ln_cache = layernorm_forward(
        x_group,
        ln_gamma,
        ln_beta,
        gn_param
    )

    x_norm = x_norm.reshape(N, C, H, W)
    out = gamma * x_norm + beta
    cache = (G, x_norm, gamma, ln_cache)

    return out, cache

layernorm_forward에는 정규화만 맡기기 위해 임시 ln_gamma=1, ln_beta=0을 사용한다. 이후 채널별로 학습되는 실제 gamma, beta를 (1,C,1,1)(1,C,1,1) shape으로 적용한다.

Backward

Affine transform out=γx^+βout=\gamma\hat{x}+\beta의 gradient를 먼저 계산한다.

dγc=∑n,h,wdoutn,c,h,wx^n,c,h,wd\gamma_c = \sum_{n,h,w} dout_{n,c,h,w}\hat{x}_{n,c,h,w} dβc=∑n,h,wdoutn,c,h,wd\beta_c = \sum_{n,h,w} dout_{n,c,h,w}

그다음 dx_norm = dout * gamma를 (NG,D)(NG,D)로 바꾸고 LayerNorm backward를 통과시킨다.

def spatial_groupnorm_backward(dout, cache):
    G, x_norm, gamma, ln_cache = cache
    N, C, H, W = dout.shape

    dgamma = np.sum(
        dout * x_norm,
        axis=(0, 2, 3),
        keepdims=True
    )
    dbeta = np.sum(
        dout,
        axis=(0, 2, 3),
        keepdims=True
    )

    dx_norm = dout * gamma
    dx_group = dx_norm.reshape(N * G, -1)
    dx_group, _, _ = layernorm_backward(dx_group, ln_cache)
    dx = dx_group.reshape(N, C, H, W)

    return dx, dgamma, dbeta

추가 개념들

Convolution backward를 행렬 수준에서 보기

stride가 11이고 padding이 없는 간단한 예시를 생각한다.

X=[x11x12x13x21x22x23x31x32x33],F=[f11f12f21f22]X = \begin{bmatrix} x_{11} & x_{12} & x_{13} \\ x_{21} & x_{22} & x_{23} \\ x_{31} & x_{32} & x_{33} \end{bmatrix}, \qquad F = \begin{bmatrix} f_{11} & f_{12} \\ f_{21} & f_{22} \end{bmatrix}

filter를 왼쪽 위에서부터 한 칸씩 이동시키면 출력은 다음과 같은 2×22\times2 행렬이 된다. 계산을 단순하게 보기 위해 bias는 생략한다.

O=[o11o12o21o22]O = \begin{bmatrix} o_{11} & o_{12} \\ o_{21} & o_{22} \end{bmatrix}

각 출력 원소는 현재 입력 window와 filter를 같은 위치끼리 곱한 뒤 모두 더한 값이다.

o11=x11f11+x12f12+x21f21+x22f22,o12=x12f11+x13f12+x22f21+x23f22,o21=x21f11+x22f12+x31f21+x32f22,o22=x22f11+x23f12+x32f21+x33f22\begin{aligned} o_{11} &= x_{11}f_{11} +x_{12}f_{12} +x_{21}f_{21} +x_{22}f_{22}, \\ o_{12} &= x_{12}f_{11} +x_{13}f_{12} +x_{22}f_{21} +x_{23}f_{22}, \\ o_{21} &= x_{21}f_{11} +x_{22}f_{12} +x_{31}f_{21} +x_{32}f_{22}, \\ o_{22} &= x_{22}f_{11} +x_{23}f_{12} +x_{32}f_{21} +x_{33}f_{22} \end{aligned}

출력 OO와 upstream gradient GG는 모두 2×22\times2이다.

G=∂L∂O=[g11g12g21g22]G = \frac{\partial L}{\partial O} = \begin{bmatrix} g_{11} & g_{12} \\ g_{21} & g_{22} \end{bmatrix}

Filter gradient

f11f_{11}은 모든 출력 위치에서 공유된다. 따라서 각 위치에서 함께 곱해졌던 입력과 upstream gradient의 곱을 모두 더한다.

∂L∂f11=g11x11+g12x12+g21x21+g22x22\frac{\partial L}{\partial f_{11}} = g_{11}x_{11} +g_{12}x_{12} +g_{21}x_{21} +g_{22}x_{22}

이는 f11f_{11} 하나가 네 출력에 모두 사용되기 때문이다.

o11⊃x11f11,o12⊃x12f11,o21⊃x21f11,o22⊃x22f11\begin{aligned} o_{11}&\supset x_{11}f_{11}, \\ o_{12}&\supset x_{12}f_{11}, \\ o_{21}&\supset x_{21}f_{11}, \\ o_{22}&\supset x_{22}f_{11} \end{aligned}

연쇄법칙으로 적으면 하나의 filter parameter가 영향을 준 모든 출력 경로의 gradient를 합하는 형태가 된다.

∂L∂f11=∑i,j∂L∂oij∂oij∂f11\frac{\partial L}{\partial f_{11}} = \sum_{i,j} \frac{\partial L}{\partial o_{ij}} \frac{\partial o_{ij}}{\partial f_{11}}

나머지 filter 원소도 같은 방식으로 계산한다.

∂L∂f12=g11x12+g12x13+g21x22+g22x23,∂L∂f21=g11x21+g12x22+g21x31+g22x32,∂L∂f22=g11x22+g12x23+g21x32+g22x33\begin{aligned} \frac{\partial L}{\partial f_{12}} &= g_{11}x_{12} +g_{12}x_{13} +g_{21}x_{22} +g_{22}x_{23}, \\ \frac{\partial L}{\partial f_{21}} &= g_{11}x_{21} +g_{12}x_{22} +g_{21}x_{31} +g_{22}x_{32}, \\ \frac{\partial L}{\partial f_{22}} &= g_{11}x_{22} +g_{12}x_{23} +g_{21}x_{32} +g_{22}x_{33} \end{aligned}

일반적으로 filter gradient는 다음과 같이 이해할 수 있다.

∂L∂F=Conv⁡(X,∂L∂O)\frac{\partial L}{\partial F} = \operatorname{Conv} \left( X, \frac{\partial L}{\partial O} \right)

즉 하나의 filter weight가 사용된 모든 위치에서 입력값 × upstream gradient를 누적한 결과이다.

Input gradient

입력 원소도 자신이 사용된 모든 출력으로부터 gradient를 받아야 한다.

∂L∂xmn=∑i,j∂L∂oij∂oij∂xmn\frac{\partial L}{\partial x_{mn}} = \sum_{i,j} \frac{\partial L}{\partial o_{ij}} \frac{\partial o_{ij}}{\partial x_{mn}}

모서리의 x11x_{11}은 o11o_{11}에만 사용되지만, 중앙의 x22x_{22}는 네 출력 모두에 영향을 준다.

출력x22x_{22}와 곱해진 filter weight
o11o_{11}f22f_{22}
o12o_{12}f21f_{21}
o21o_{21}f12f_{12}
o22o_{22}f11f_{11}

따라서 연쇄법칙에 따라 네 경로의 gradient를 합치면 다음과 같다.

∂L∂x22=g11f22+g12f21+g21f12+g22f11\frac{\partial L}{\partial x_{22}} = g_{11}f_{22} +g_{12}f_{21} +g_{21}f_{12} +g_{22}f_{11}

전체 입력 gradient를 원소별로 펼치면 다음과 같다.

∂L∂X=[g11f11g11f12+g12f11g12f12g11f21+g21f11g11f22+g12f21+g21f12+g22f11g12f22+g22f12g21f21g21f22+g22f21g22f22]\frac{\partial L}{\partial X} = \begin{bmatrix} g_{11}f_{11} & g_{11}f_{12}+g_{12}f_{11} & g_{12}f_{12} \\ g_{11}f_{21}+g_{21}f_{11} & g_{11}f_{22}+g_{12}f_{21}+g_{21}f_{12}+g_{22}f_{11} & g_{12}f_{22}+g_{22}f_{12} \\ g_{21}f_{21} & g_{21}f_{22}+g_{22}f_{21} & g_{22}f_{22} \end{bmatrix}

2×22\times2인 GG에서 원래 입력과 같은 3×33\times3 gradient를 만들어야 하므로 valid convolution이 아니라 FullConv가 필요하다. 이때 filter를 180∘180^\circ 회전한다.

∂L∂X=FullConv⁡(∂L∂O,rot180⁡(F))\frac{\partial L}{\partial X} = \operatorname{FullConv} \left( \frac{\partial L}{\partial O}, \operatorname{rot180}(F) \right)

filter 회전은 외워서 추가하는 별도 규칙이 아니라, 하나의 입력이 여러 출력 계산에서 filter의 서로 다른 위치와 곱해졌다는 사실을 연쇄법칙으로 풀어 쓴 결과이다.

이번 naive loop에서는 회전된 filter를 직접 만들지 않는다. 각 dout[n, f, i, j]를 forward에서 사용했던 동일한 window에 w[f]와 곱해 흩뿌리고, 겹치는 위치를 더하는 방식으로 같은 dx를 계산한다.

Stride가 1보다 클 때

입력이 5×55\times5, filter가 3×33\times3, stride가 22, padding이 00인 경우를 생각한다. 출력 크기는 다음과 같다.

H′=W′=1+⌊5−32⌋=2H' = W' = 1+\left\lfloor \frac{5-3}{2} \right\rfloor = 2

따라서 출력과 upstream gradient는 모두 2×22\times2이다. forward에서 filter의 왼쪽 위 좌표는 다음 네 위치만 방문한다.

(0,0)→(0,2)→(2,0)→(2,2)(0,0) \rightarrow (0,2) \rightarrow (2,0) \rightarrow (2,2)

stride가 11이면 중간 위치도 방문하지만, stride가 22이면 한 칸씩 건너뛴다. 이를 convolution 식으로 표현한 backward에서는 출력 gradient 사이에 00을 삽입해 원래 간격을 복원한 dilated gradient를 생각할 수 있다.

[g00g01g10g11]→[g000g01000g100g11]\begin{bmatrix} g_{00} & g_{01} \\ g_{10} & g_{11} \end{bmatrix} \rightarrow \begin{bmatrix} g_{00} & 0 & g_{01} \\ 0 & 0 & 0 \\ g_{10} & 0 & g_{11} \end{bmatrix}

빠진 위치는 forward 출력에 존재하지 않았으므로 그 위치에서 내려오는 gradient도 00이다. stride가 SS라면 인접한 gradient 사이에 일반적으로 S−1S-1개의 00을 넣는다.

Stride 2의 input gradient

dx를 convolution 형태로 계산하는 순서는 다음과 같다.

  1. GG의 원소 사이에 00을 넣어 GdilatedG_{\text{dilated}}를 만든다.
  2. 원래 입력 영역까지 gradient가 퍼질 수 있도록 가장자리를 padding한다.
  3. filter를 180∘180^\circ 회전한다.
  4. FullConv를 수행해 입력과 같은 5×55\times5 shape을 복원한다.

따라서 다음과 같이 정리할 수 있다.

∂L∂X=FullConv⁡(Gdilated,rot180⁡(F))\frac{\partial L}{\partial X} = \operatorname{FullConv} \left( G_{\text{dilated}}, \operatorname{rot180}(F) \right)

반면 naive loop에서는 hs = i * stride, ws = j * stride로 실제 forward에서 방문했던 window에만 gradient를 누적한다. 건너뛴 출력 위치에는 애초에 반복문 항이 없으므로 별도의 dilation 배열을 만들 필요가 없다.

Stride 2의 filter gradient

stride가 커져도 하나의 filter weight는 모든 출력 위치에서 공유된다. 따라서 filter gradient의 기본 연쇄법칙은 달라지지 않는다.

∂L∂fuv=∑i,j∂L∂oij∂oij∂fuv\frac{\partial L}{\partial f_{uv}} = \sum_{i,j} \frac{\partial L}{\partial o_{ij}} \frac{\partial o_{ij}}{\partial f_{uv}}

예를 들어 f00f_{00}은 stride 간격에 따라 x00x_{00}, x02x_{02}, x20x_{20}, x22x_{22}와 곱해진다.

∂L∂f00=g00x00+g01x02+g10x20+g11x22\frac{\partial L}{\partial f_{00}} = g_{00}x_{00} +g_{01}x_{02} +g_{10}x_{20} +g_{11}x_{22}

일반화하면 다음과 같다.

∂L∂fuv=∑i,jgijxiS+u,jS+v\frac{\partial L}{\partial f_{uv}} = \sum_{i,j} g_{ij} x_{iS+u,jS+v}

코드에서는 각 출력 위치의 입력 window 전체에 dout[n, f, i, j]를 곱한 뒤 dw[f]에 누적하므로 이 식을 그대로 구현한다.

수식과 naive loop의 대응

수식에서는 filter 회전, full convolution, gradient dilation이 눈에 띈다. 하지만 이번 구현은 각 출력 원소가 사용했던 입력 window로 gradient를 직접 되돌려 보내는 방식이라 이 배열들을 명시적으로 만들지 않는다.

역전파 대상수식 관점naive loop에서의 계산
dbdb같은 filter의 모든 출력 gradient 합db[f] += dout[n, f, i, j]
dwdw입력 window와 GG의 Convdw[f] += window * dout[n, f, i, j]
dxdxdilated GG와 filter의 full 연산해당 window에 w[f] * dout[n, f, i, j] 누적

특히 dx_pad[...] += ...에서 대입이 아니라 누적을 사용해야 한다. receptive field가 겹치면 하나의 입력이 여러 출력에 참여하므로 각 경로에서 돌아온 gradient를 모두 더해야 하기 때문이다.

Forward와 backward의 shape

각 gradient는 미분 대상과 같은 shape을 가진다.

값Shape
xx, dxdx(N,C,H,W)(N,C,H,W)
ww, dwdw(F,C,HH,WW)(F,C,HH,WW)
bb, dbdb(F,)(F,)
outout, doutdout(N,F,H′,W′)(N,F,H',W')

backward 구현 중 shape이 헷갈릴 때는 dx, dw, db가 각각 x, w, b와 같은 shape으로 돌아와야 한다는 점을 먼저 확인하면 된다.

참고: 딥러닝 라이브러리에서 convolution이라고 부르는 연산은 엄밀히 말하면 filter를 뒤집지 않는 cross-correlation인 경우가 많다. 이 글에서는 PDF의 표기에 맞춰 Conv와 FullConv로 적었다.