본문 바로가기
DeepLearning/CS231n

CS231n Assignment 2: Q4: ConvolutionalNetworks

by Dev_PSS 2026. 6. 7.

내 풀이 github LINK: https://github.com/qkrtmdtj04/CS231n-Assignment

 

GitHub - qkrtmdtj04/CS231n-Assignment

Contribute to qkrtmdtj04/CS231n-Assignment development by creating an account on GitHub.

github.com

 


Convolutional Layer의 문제의식

FcLayer(CS231n Lecture5)

FC(Fully Connected) 레이어는 입력을 전부 1차원으로 펼쳐버린다. 이미지가 (H, W, C) 구조를 가지고 있어도, flatten 하는 순간 공간 정보가 사라진다. "왼쪽 눈 픽셀"과 "오른쪽 눈 픽셀"이 단순히 벡터의 두 원소가 되어버리는 것이다.

Conv Layer(CS231n Lecture5)

Conv 레이어는 이 문제를 필터(filter)로 해결한다. 작은 필터를 입력 전체를 슬라이딩하면서 찍어내는 구조이기 때문에, 공간적으로 가까운 픽셀들이 함께 처리된다. 덕분에 에지, 코너, 질감 같은 지역 패턴을 자연스럽게 잡아낼 수 있다.

또 하나의 큰 장점은 파라미터 공유다. 동일한 필터를 입력 전체에 반복 적용하기 때문에, (H, W, C) 입력을 FC로 처리할 때보다 파라미터 수가 극적으로 줄어든다.


Convolution Forward Pass

Convolution 연산은 입력 위에 필터를 슬라이딩하면서 내적을 계산하는 것이다. 입력이 (N, C, H, W), 필터가 (F, C, HH, WW)일 때 출력 shape은 아래와 같다.

H_out = (H + 2*pad - HH) / stride + 1
W_out = (W + 2*pad - WW) / stride + 1
출력 shape: (N, F, H_out, W_out)

각 출력 위치 (n, f, i, j)는 입력 이미지 n의 (i, j) 주변 패치와 필터 f의 내적 + 편향 b[f]다. padding은 필터가 이미지 경계를 처리할 수 있도록 입력 주위에 0을 채우는 것이다.


Convolution Backward Pass

Backward에서는 upstream gradient dout으로부터 dx, dw, db를 구한다.

Forward에서 out[n,f,i,j] = sum(patch * w[f]) + b[f]였으므로, 각 기울기는 아래와 같이 흐른다.

db[f]          = Σ dout[n,f,i,j]            (모든 출력 위치 합산)
dw[f]          += dout[n,f,i,j] * patch     (패치와의 내적 누적)
dx_pad[패치 위치] += dout[n,f,i,j] * w[f]  (기울기를 원래 위치로 scatter)

forward에서 patch × w[f]로 모았다면, backward에서는 dout × w[f]를 다시 원래 위치로 흩뿌리는 것이다. 이때 dx는 패딩된 배열에 먼저 누적한 뒤 패딩 영역을 잘라내야 한다.

* patch : w랑 곱해지는 x의 구역


Max Pooling

Max Pooling은 각 풀링 내 최댓값만 통과시키는 연산이다. 학습 파라미터가 없고, 역할은 두 가지다.

  • 공간 해상도를 줄여 연산량·메모리 절약
  • 작은 위치 변화에 강건한 표현 학습

Backward: 기울기는 오직 최댓값을 가졌던 위치로만 흐른다. forward에서 최댓값의 위치(mask)를 cache에 기억해 두고, backward에서 그 위치에만 dout을 전달한다. 나머지 위치의 기울기는 0이다.


Spatial Batch Normalization

 일반 BN은 (N, D) 입력에서 N 방향으로 정규화하는데, Conv 레이어 출력은 (N, C, H, W) shape이다. 이를 그대로 BN에 넣을 수는 없다.

 핵심 아이디어는 같은 필터가 이미지 전체를 슬라이딩해 만든 feature map이므로, 같은 채널의 모든 공간 위치는 통계적으로 일관성이 있다는 것이다. 따라서 N × H × W를 하나의 미니배치처럼 묶어 채널 C별로 평균/분산을 구한다.

일반 BN    : (N, D)       → N 방향 집계       → D개 통계량
Spatial BN : (N, C, H, W) → N, H, W 방향 집계 → C개 통계량

구현 상 trick은 (N, C, H, W) → (N*H*W, C)로 reshape만 하면 기존 batchnorm_forward를 그대로 재사용할 수 있다는 것이다.


Spatial Group Normalization

BN은 배치 크기에 의존하고, LN은 Conv 레이어에서 성능이 떨어진다는 한계가 있다. Group Normalization(GN)은 그 중간을 택한다.

채널 C를 G개 그룹으로 나누고, 각 그룹 내에서 독립적으로 정규화한다.

BN : N, H, W 방향 집계    → C개 통계량   (배치 의존)
LN : C, H, W 방향 집계    → N개 통계량
GN : C/G, H, W 방향 집계  → N*G개 통계량 (배치 독립)

G=C이면 Instance Normalization, G=1이면 Layer Normalization과 동일해진다.

GN이 효과적인 이유는 시각 인식 특징이 자연스럽게 그룹을 이루는 경향이 있기 때문이다. HOG(Histogram of Oriented Gradients) 같은 전통 특징도 공간 블록별 히스토그램을 각각 정규화한 뒤 연결하는 구조를 갖는다.


네트워크 구조

이번 과제에서 구현하는 기본 구조는 아래와 같다.

[conv - relu - 2x2 max pool] → [FC - relu] → [FC] → softmax

Q4-1: conv_forward_naive

def conv_forward_naive(x, w, b, conv_param):

    out = None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    stride, pad = conv_param['stride'], conv_param['pad']
    pad_x = np.pad(x, ((0,0),(0,0),(pad,pad),(pad,pad)))
    xn, xc, xh, xw = pad_x.shape
    wf, wc, wh, ww = w.shape

    conv_move_h = (xh - wh) // stride + 1
    conv_move_w = (xw - ww) // stride + 1

    out = np.zeros((xn, wf, conv_move_h, conv_move_w))

    for n in range(xn):
        for f in range(wf):
            for move_h in range(conv_move_h):
                for move_w in range(conv_move_w):
                    out[n, f, move_h, move_w] = np.sum(
                        w[f] * pad_x[n, :, move_h*stride:move_h*stride+wh,
                                        move_w*stride:move_w*stride+ww]
                    ) + b[f]

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    cache = (x, w, b, conv_param)
    return out, cache
  • naive라는 이름답게 4중 for문으로 구현했다. 연산 자체는 단순한 내적이라 이해하기 쉽다. np.pad에서 ((0,0),(0,0),(pad,pad),(pad,pad)) 순서가 (N, C, H, W) 각 축에 대응한다.

  • 위의 Conv Layer를 실험해 보기 위해 임의의 필터를 씌우면 이렇게 이미지가 잘 변환되는 것을 볼 수 있다.

 

Q4-2: conv_backward_naive

def conv_backward_naive(dout, cache):

    dx, dw, db = None, None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    x, w, b, conv_param = cache
    
    db = np.sum(np.sum(np.sum(dout,axis=0),axis=1),axis=1)
    stride = conv_param['stride']
    pad = conv_param['pad']

    
    x = np.pad(x, ((0,0),(0,0),(pad,pad),(pad,pad)))
    xn, xc, xh, xw = x.shape
    wf, wc, wh, ww = w.shape
    conv_move_h = (xh - wh) // stride + 1
    conv_move_w = (xw - ww) // stride + 1

        
    dx = np.zeros_like(x)
    dw = np.zeros_like(w)

    for n in range(xn):
        for f in range(wf):
            for move_h in range(conv_move_h):
                for move_w in range(conv_move_w):
                    dx[n,:,move_h*stride:move_h*stride+wh,move_w*stride:move_w*stride+ww] += dout[n][f][move_h][move_w] * w[f,:,:,:]
                    dw[f,:,:,:] += dout[n][f][move_h][move_w] * x[n,:,
                                                                    move_h*stride:move_h*stride+wh,
                                                                    move_w*stride:move_w*stride+ww]
    dx = dx[:,:,pad:-pad,pad:-pad]
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    ###########################################################################
    #                             END OF YOUR CODE                            #
    ###########################################################################
    return dx, dw, db
  • 순전파 연산처럼 순서를 진행하며 계산하면 된다.
  • 주의할 점은 dx를 패딩 된 배열 dx_pad에 누적한 뒤 마지막에 잘라낸다는 것이다. 패딩 영역은 실제 입력이 아니므로 그쪽으로 흘러간 기울기는 버린다.
np.random.seed(231)
x    = np.random.randn(4, 3, 5, 5)
w    = np.random.randn(2, 3, 3, 3)
b    = np.random.randn(2,)
dout = np.random.randn(4, 2, 5, 5)
conv_param = {'stride': 1, 'pad': 1}

dx_num = eval_numerical_gradient_array(lambda x: conv_forward_naive(x, w, b, conv_param)[0], x, dout)
dw_num = eval_numerical_gradient_array(lambda w: conv_forward_naive(x, w, b, conv_param)[0], w, dout)
db_num = eval_numerical_gradient_array(lambda b: conv_forward_naive(x, w, b, conv_param)[0], b, dout)

out, cache = conv_forward_naive(x, w, b, conv_param)
dx, dw, db = conv_backward_naive(dout, cache)

# errors should be around e-8 or less
print('Testing conv_backward_naive function')
print('db error: ', rel_error(db, db_num))
print('dx error: ', rel_error(dx, dx_num))
print('dw error: ', rel_error(dw, dw_num))

Q4-3: max_pool_forward_naive

def max_pool_forward_naive(x, pool_param):
    out = None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    ph = pool_param['pool_height']
    pw = pool_param['pool_width']
    xn,xc,xh,xw = x.shape
    stride = pool_param['stride']
    move_h = (xh-ph)//stride +1
    move_w = (xw-pw)//stride +1


    out = np.zeros((xn,xc,move_h,move_w))
    for n in range(xn):
        for c in range(xc):
            for h in range(move_h):
                for w in range(move_w):
                    out[n][c][h][w] = np.max(x[n,c,h*stride:h*stride+ph,w*stride:w*stride+pw])
    #print(out.shape)
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    cache = (x, pool_param)
    return out, cache

conv와 달리 padding이 없고, 채널별로 독립 처리하기 때문에 for문에 c가 추가된다. 학습 파라미터가 없으므로 cache에 입력 x와 pool_param만 저장한다.


Q4-4: max_pool_backward_naive

def max_pool_backward_naive(dout, cache):

    dx = None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    x, pool_param = cache
    ph = pool_param['pool_height']
    pw = pool_param['pool_width']
    xn,xc,xh,xw = x.shape
    stride = pool_param['stride']
    move_h = (xh-ph)//stride +1
    move_w = (xw-pw)//stride +1

    dx = np.zeros_like(x)

    for n in range(xn):
        for c in range(xc):
            for h in range(move_h):
                for w in range(move_w):
                    max_index = np.argmax(x[n,c,h*stride:h*stride+ph,w*stride:w*stride+pw])

                    dx[n,c][h*stride+max_index//ph][w*stride+max_index%ph] += dout[n][c][h][w]


    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return dx
  • 이 구현의 핵심은 np.argmax를 이용해 패치 내 최댓값의 위치를 찾고, 이를 바탕으로 원래의 2차원 좌표를 복원하여 기울기를 전달하는 것입니다. 그리고 해당 위치에만 상류에서 흘러온 기울기(dout)를 더해줍니다.

 


번외: Fast Layers

naive 구현은 Python for문 기반이라 실용적으로 쓰기 어렵다. CS231n은 Cython으로 작성된 fast 버전을 제공하는데, API는 naive와 완전히 동일하다.

Testing conv_forward_fast:
Naive: 4.564996s
Fast: 0.013453s
Speedup: 339.323001x
Difference:  4.926407851494105e-11

Testing conv_backward_fast:
Naive: 11.290497s
Fast: 0.011504s
Speedup: 981.466839x
dx difference:  1.949764775345631e-11
dw difference:  3.681156828004736e-13
db difference:  1.0120564794180781e-14
  • 확실히 4~10배가 빨라진 것을 보면 python loop가 얼마나 느린지 벡터연산이 왜 중요한지 알 거 같다.

 


Q4-7: Three-Layer Convolutional Network

cs231n/classifiers/cnn.py에 ThreeLayerConvNet 클래스를 구현한다.

class ThreeLayerConvNet(object):

    def __init__(
        self,
        input_dim=(3, 32, 32),
        num_filters=32,
        filter_size=7,
        hidden_dim=100,
        num_classes=10,
        weight_scale=1e-3,
        reg=0.0,
        dtype=np.float32,
    ):

        self.params = {}
        self.reg = reg
        self.dtype = dtype

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        self.params["W1"] = np.random.randn(num_filters,input_dim[0],filter_size,filter_size) *weight_scale
        self.params["b1"] = np.zeros([num_filters,])
        conv_h = (input_dim[1] - filter_size + 2*((filter_size - 1) // 2))//1 + 1
        maxpool_h = (conv_h-2)//2 + 1
        maxpool_w = maxpool_h

        self.params["W2"] = np.random.randn(num_filters*maxpool_h*maxpool_w,hidden_dim)*weight_scale
        self.params["b2"] = np.zeros([hidden_dim,])

        self.params["W3"] = np.random.randn(hidden_dim,num_classes)*weight_scale
        self.params["b3"] = np.zeros([num_classes,])

        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****


        for k, v in self.params.items():
            self.params[k] = v.astype(dtype)

    def loss(self, X, y=None):

        W1, b1 = self.params["W1"], self.params["b1"]
        W2, b2 = self.params["W2"], self.params["b2"]
        W3, b3 = self.params["W3"], self.params["b3"]

        filter_size = W1.shape[2]
        conv_param = {"stride": 1, "pad": (filter_size - 1) // 2}


        pool_param = {"pool_height": 2, "pool_width": 2, "stride": 2}

        scores = None

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        out, cache_conv = conv_relu_pool_forward(X,W1,b1,conv_param,pool_param)
        out1, cache_ar = affine_relu_forward(out,W2,b2)
        scores, cache_a = affine_forward(out1,W3,b3)
        

        
        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****


        if y is None:
            return scores

        loss, grads = 0, {}

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        loss,dx = softmax_loss(scores,y)
        loss +=  0.5*self.reg*(np.sum(np.square(W1)) + np.sum(np.square(W2)) + np.sum(np.square(W3)))
        dx3, dw3, db3 = affine_backward(dx,cache_a)
        dw3 += self.reg * W3
        grads['W3'] = dw3
        grads['b3'] = db3
        dx2, dw2, db2 = affine_relu_backward(dx3,cache_ar)
        dw2 += self.reg *  W2
        grads['W2'] = dw2
        grads['b2'] = db2
        dx1, dw1, db1 = conv_relu_pool_backward(dx2,cache_conv)
        dw1 += self.reg *  W1
        grads['W1'] = dw1
        grads['b1'] = db1


        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
        ############################################################################


        return loss, grads

 

1. __init__: 파라미터 초기화

  • 네트워크를 구성하는 가중치 W와 편향 b을 초기화합니다.

2. loss: 순전파(Forward Pass)

  • conv_relu_pool_forward: 합성곱, 활성화 함수(ReLU), 풀링을 순차적으로 수행합니다.
  • affine_relu_forward: 은닉층을 위한 완전 연결 계층과 ReLU를 적용합니다.
  • affine_forward: 최종 클래스 점수를 산출합니다.

3. loss: 역전파(Backward Pass)

계산된 점수와 정답(y)을 사용하여 손실(Loss)을 구하고, 연쇄 법칙(Chain Rule)을 통해 각 파라미터에 대한 그래디언트(Gradient)를 구합니다.

  • 정규화(Regularization): 가중치 감쇠(Weight Decay)를 위해 각 $W$의 제곱합을 손실 함수에 더해줍니다.
  • 역순 계산: 출력단에서 입력단으로 affine_backward -> affine_relu_backward -> conv_relu_pool_backward 순으로 그래디언트를 전파합니다.
  • 주의점: 가중치에 대한 그래디언트를 구할 때, 정규화 항의 미분값(reg * W)을 반드시 더해줘야 합니다.
model = ThreeLayerConvNet(weight_scale=0.001, hidden_dim=500, reg=0.001)

solver = Solver(
    model,
    data,
    num_epochs=1,
    batch_size=50,
    update_rule='adam',
    optim_config={'learning_rate': 1e-3,},
    verbose=True,
    print_every=20
)
solver.train()

 

Full data validation accuracy: 0.499
  • 이렇게 solver를 통해서 간단한 cnn모델을 돌려 보면 정확도 49%라는 FC로 했을 때보다 압도적인 성능을 보여준다.

  • 위 사진이 CNN에서 학습된 필터들의 모습이다. (이걸로 50% 맞추는 것도 참 신기하다.)

Q4-8: spatial_batchnorm_forward

def spatial_batchnorm_forward(x, gamma, beta, bn_param):

    out, cache = None, None


    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    
    N, C, H, W = x.shape
    x_T = x.transpose(0,2,3,1).reshape(-1,C)
    
    #print(x_T.shape)
    out, cache =batchnorm_forward(x_T,gamma, beta, bn_param)
    out = out.reshape(N,H,W,C).transpose(0,3,1,2)
    
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****


    return out, cache
  • Spatial BatchNorm의 핵심은 (N, C, H, W)를 (N×H×W, C) 형태로 변환하여 기존 batchnorm_forward를 재사용하는 것이다.

 


Q4-9: spatial_batchnorm_backward

def spatial_batchnorm_backward(dout, cache):

    dx, dgamma, dbeta = None, None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    
    N, C, H, W = dout.shape
    dout_T = dout.transpose(0, 2, 3, 1).reshape(-1, C)
    
    dx_T, dgamma, dbeta = batchnorm_backward_alt(dout_T, cache)
    
    dx = dx_T.reshape(N, H, W, C).transpose(0, 3, 1, 2)

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****


    return dx, dgamma, dbeta

forward와 완전히 같은 패턴이다. batchnorm_backward_alt를 재사용하고, shape만 forward와 동일한 순서로 변환하면 된다.

Q4-10: spatial_groupnorm_forward

def spatial_groupnorm_forward(x, gamma, beta, G, gn_param):
    out, cache = None, None
    eps = gn_param.get("eps", 1e-5)

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    N, C, H, W = x.shape
    x_reshape = np.reshape(x,[N,G,C//G,W*H])
    x_mean = np.mean(x_reshape, axis=(2, 3), keepdims=True)
    x_std = np.sqrt(np.var(x_reshape, axis=(2, 3), keepdims=True) + eps)
    x_het = (x_reshape - x_mean) / x_std
    x_het = np.reshape(x_het,x.shape)

    out = gamma * x_het + beta

    cache = x,x_het,x_std,gamma,beta,G
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return out, cache
  • 핵심은 (N, C, H, W)를 (N, G, C//G, H*W)로 reshape 해 그룹 차원을 만드는 것이다. 그 뒤 axis=(2, 3) 방향으로 평균과 분산을 계산하면 각 (n, g) 그룹마다 독립적인 통계량을 얻을 수 있다. 이때 keepdims=True를 사용해야 평균과 표준편차의 shape이 (N, G, 1, 1)로 유지되어 이후 정규화 과정에서 브로드캐스팅이 올바르게 수행된다.

Q4-11: spatial_groupnorm_backward

def spatial_groupnorm_backward(dout, cache):

    dx, dgamma, dbeta = None, None, None


    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    N, C, H, W = dout.shape
    x,x_het,x_std,gamma,beta,G = cache
    dbeta = np.sum(dout,axis=(0,2,3),keepdims=True)
    dgamma = np.sum(x_het*dout,axis=(0,2,3),keepdims=True)
    
    D = (C//G) * H * W  # 그룹당 정규화 원소 수

    # (N, C, H, W) → (N, G, C//G, H*W) 로 reshape
    dxhet  = np.reshape(gamma * dout, (N, G, C//G, H*W))
    x_het_r = np.reshape(x_het,(N, G, C//G, H*W))

    dx = 1/(D*x_std) * (D*dxhet - np.sum(dxhet,axis=(2,3),keepdims=True) - x_het_r*np.sum(dxhet*x_het_r,axis=(2,3),keepdims=True))
    dx = np.reshape(dx,dout.shape)

    #print(dx.shape)
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return dx, dgamma, dbeta

Q2 batchnorm_backward_alt 공식을 그대로 재활용한다. 달라진 것은 N이 D로, axis=0이 axis=(2,3)로 바뀐 것뿐이다. BN alt를 이해했다면 GN backward는 축만 바꾸면 된다.


마무리

이번 과제의 포인트는 Q2에서 만든 batchnorm_forward/backward_alt가 Spatial BN, GN에서 shape 변환 하나로 그대로 재활용된다는 것이었다. 모듈화가 잘 된 코드가 얼마나 개꿀인지....

naive conv의 4중 for문이 얼마나 느린지 fast 버전과 비교하면서 최적화가 ㄹㅇ 중요한 거 같다.