Assignment 1-3: Two-Layer Neural Network

ASSIGNMENT 글 목록
목차

Overview

Fully-Connected Neural Nets 과제에서는 neural network를 구성하는 기본 layer들을 모듈 단위로 구현한다.
이번 과제에서 직접 구현한 핵심은 affine_forward, affine_backward, relu_forward, relu_backward, softmax_loss, 그리고 이들을 조합한 TwoLayerNet이다.

전체 구조는 다음과 같다.

affineReLUaffinesoftmax\text{affine} \rightarrow \text{ReLU} \rightarrow \text{affine} \rightarrow \text{softmax}

즉 입력 이미지를 먼저 하나의 긴 벡터처럼 펼친 뒤, 첫 번째 affine layer에서 hidden feature를 만들고, ReLU로 비선형성을 넣고, 두 번째 affine layer에서 class score를 계산한다. 마지막 softmax loss는 score를 확률처럼 해석해서 정답 class에 대한 loss를 계산한다.

핵심 아이디어

Two-layer net은 두 번의 affine transformation 사이에 ReLU nonlinearity를 넣은 가장 기본적인 fully-connected neural network이다.

Linear classifier에서는 하나의 선형 함수만 사용했다.

s=XW+bs = XW + b

Two-layer net에서는 중간 hidden layer를 하나 둔다.

h=ReLU(XW1+b1)h = \text{ReLU}(XW_1 + b_1) s=hW2+b2s = hW_2 + b_2

여기서 각 기호의 의미는 다음과 같다.

  • XX: 입력 데이터
  • W1,b1W_1, b_1: 첫 번째 affine layer의 weight와 bias
  • hh: hidden layer activation
  • W2,b2W_2, b_2: 두 번째 affine layer의 weight와 bias
  • ss: 각 class에 대한 score

입력 shape을 기준으로 보면 다음과 같다.

XRN×d1××dkX \in \mathbb{R}^{N \times d_1 \times \cdots \times d_k}

이미지 하나를 펼친 차원을 D=d1dkD=d_1 \cdots d_k라고 하면, affine layer 내부에서는 입력을 다음처럼 바꿔서 사용한다.

XflatRN×DX_{\text{flat}} \in \mathbb{R}^{N \times D}

첫 번째 layer는 hidden dimension을 HH, class 개수를 CC라고 할 때 다음 shape을 가진다.

W1RD×H,b1RHW_1 \in \mathbb{R}^{D \times H}, \quad b_1 \in \mathbb{R}^{H} W2RH×C,b2RCW_2 \in \mathbb{R}^{H \times C}, \quad b_2 \in \mathbb{R}^{C}

따라서 forward pass의 shape 흐름은 다음과 같다.

X(N,D)(N,H)(N,H)(N,C)X \rightarrow (N,D) \rightarrow (N,H) \rightarrow (N,H) \rightarrow (N,C)

마지막 (N, C)가 각 sample마다 class score를 담은 scores이다.

구현

Affine forward

Affine layer는 fully-connected layer와 같은 의미로 보면 된다.
입력 x가 이미지처럼 (N, d1, ..., dk) 형태여도, 내부에서는 각 sample을 하나의 벡터로 펼쳐서 (N, D)로 만든 뒤 행렬곱을 수행한다.

def affine_forward(x, w, b):
    N = x.shape[0]
    x_flat = x.reshape(N, -1)  # (N, D)
    out = x_flat @ w + b
    cache = (x, w, b)

    return out, cache

shape은 다음과 같다.

xflatRN×D,wRD×M,bRMx_{\text{flat}} \in \mathbb{R}^{N \times D}, \quad w \in \mathbb{R}^{D \times M}, \quad b \in \mathbb{R}^{M} out=xflatw+bRN×Mout = x_{\text{flat}}w + b \in \mathbb{R}^{N \times M}

여기서 cache는 backward pass에서 gradient를 계산할 때 다시 필요한 값들을 저장하는 용도이다.

Affine backward

Affine layer의 backward pass에서는 upstream gradient dout이 들어온다.
dout은 이 layer의 출력 out에 대한 loss의 gradient이다.

def affine_backward(dout, cache):
    x, w, b = cache
    N = x.shape[0]

    # 입력을 순전파 때와 같은 방식으로 펼침
    x_flat = x.reshape(N, -1)

    # dx_flat = dout(N, M) @ w.T(M, D)
    dx_flat = dout @ w.T
    dx = dx_flat.reshape(x.shape)

    # dw = x_flat.T(D, N) @ dout(N, M)
    dw = x_flat.T @ dout

    # db는 배치 방향으로 더함
    db = np.sum(dout, axis=0)

    return dx, dw, db

핵심 shape은 다음과 같다.

doutRN×Mdout \in \mathbb{R}^{N \times M} dxflat=dout  wTdx_{\text{flat}} = dout \; w^T (N,M)(M,D)(N,D)(N,M)(M,D) \rightarrow (N,D)

dx는 원래 입력 x와 같은 shape으로 되돌려야 한다.
그래서 마지막에 dx_flat.reshape(x.shape)를 사용한다.

weight gradient는 다음과 같다.

dw=xflatTdoutdw = x_{\text{flat}}^T dout (D,N)(N,M)(D,M)(D,N)(N,M) \rightarrow (D,M)

bias gradient는 모든 sample에 대해 같은 bias가 더해졌기 때문에 batch 방향으로 합산한다.

db=i=1Ndoutidb = \sum_{i=1}^{N} dout_i

ReLU forward / backward

ReLU는 0보다 작은 값은 0으로 만들고, 0보다 큰 값은 그대로 통과시키는 함수이다.

ReLU(x)=max(0,x)\text{ReLU}(x) = \max(0,x)
def relu_forward(x):
    out = np.maximum(0, x)
    cache = x

    return out, cache

backward에서는 forward input x가 0보다 컸던 위치만 gradient를 통과시킨다.

def relu_backward(dout, cache):
    x = cache
    dx = dout * (x > 0)

    return dx

즉 ReLU의 local gradient는 다음과 같다.

ReLU(x)x={1x>00x0\frac{\partial \text{ReLU}(x)}{\partial x} = \begin{cases} 1 & x > 0 \\ 0 & x \le 0 \end{cases}

그래서 dout * (x > 0) 형태로 구현할 수 있다.

Softmax loss

Softmax loss는 score를 확률 분포로 바꾼 뒤, 정답 class 확률에 negative log를 씌워 loss를 계산한다.

def softmax_loss(x, y):
    loss, dx = None, None
    N = x.shape[0]

    shifted_x = x - np.max(x, axis=1, keepdims=True)
    exp_score = np.exp(shifted_x)

    probs = exp_score / np.sum(exp_score, axis=1, keepdims=True)

    log_probs = -np.log(probs)
    correct_log_probs = log_probs[np.arange(N), y]
    loss = np.sum(correct_log_probs) / N

    dx = probs.copy()
    dx[np.arange(N), y] -= 1
    dx /= N

    return loss, dx

shifted_x를 사용하는 이유는 softmax의 수치 안정성 때문이다.
score가 너무 크면 np.exp에서 overflow가 날 수 있으므로, 각 sample마다 가장 큰 score를 빼준다.

si,j=si,jmaxjsi,js'_{i,j} = s_{i,j} - \max_j s_{i,j}

softmax는 모든 score에 같은 상수를 빼도 확률값이 변하지 않는다.

eskcjesjc=eceskecjesj=eskjesj\frac{e^{s_k-c}}{\sum_j e^{s_j-c}} = \frac{e^{-c}e^{s_k}}{e^{-c}\sum_j e^{s_j}} = \frac{e^{s_k}}{\sum_j e^{s_j}}

softmax gradient는 이전 과제에서 본 것처럼 probs - one_hot 형태이다.

dx=1N(PT)dx = \frac{1}{N}(P - T)

여기서 PP는 softmax probability matrix, TT는 one-hot 정답 matrix이다.

TwoLayerNet

TwoLayerNet에서는 앞에서 구현한 layer들을 조합한다.
learnable parameter는 self.params dictionary에 저장한다.

class TwoLayerNet(object):
    def __init__(
        self,
        input_dim=3 * 32 * 32,
        hidden_dim=100,
        num_classes=10,
        weight_scale=1e-3,
        reg=0.0,
    ):
        self.params = {}
        self.reg = reg

        W1 = weight_scale * np.random.randn(input_dim, hidden_dim)
        b1 = np.zeros(hidden_dim)
        W2 = weight_scale * np.random.randn(hidden_dim, num_classes)
        b2 = np.zeros(num_classes)

        self.params["W1"] = W1
        self.params["b1"] = b1
        self.params["W2"] = W2
        self.params["b2"] = b2

초기화에서 weight는 평균 0, 표준편차 weight_scale인 Gaussian distribution으로 초기화하고, bias는 0으로 초기화한다.

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

        W1 = self.params["W1"]
        W2 = self.params["W2"]
        b1 = self.params["b1"]
        b2 = self.params["b2"]

        # 첫 번째 affine + ReLU 계층: (N, H)
        hidden, cache1 = affine_relu_forward(X, W1, b1)

        # 두 번째 affine 계층: (N, C)
        scores, cache2 = affine_forward(hidden, W2, b2)

        # y가 None이면 테스트 모드이므로 점수만 반환
        if y is None:
            return scores

        loss, grads = 0, {}

        loss, dscores = softmax_loss(scores, y)

        loss += 0.5 * self.reg * (
            np.sum(W1 * W1) +
            np.sum(W2 * W2)
        )

        dhidden, dW2, db2 = affine_backward(dscores, cache2)
        dX, dW1, db1 = affine_relu_backward(dhidden, cache1)

        dW1 += self.reg * W1
        dW2 += self.reg * W2

        grads["W1"] = dW1
        grads["b1"] = db1
        grads["W2"] = dW2
        grads["b2"] = db2

        return loss, grads

forward 흐름은 다음과 같다.

XW1,b1XW1+b1ReLUhW2,b2scoresX \xrightarrow[]{W_1,b_1} XW_1+b_1 \xrightarrow[]{ReLU} h \xrightarrow[]{W_2,b_2} scores

backward 흐름은 반대이다.

dscoresdW2,db2,dhdW1,db1,dXdscores \rightarrow dW_2, db_2, dh \rightarrow dW_1, db_1, dX

여기서 affine_relu_forward, affine_relu_backward는 affine layer와 ReLU layer를 묶은 helper function이다.
직접 구현한 affine_forward, relu_forward, relu_backward, affine_backward를 순서대로 호출하는 구조라고 보면 된다.

추가 개념들

왜 ReLU를 사이에 넣는가

두 affine layer를 그냥 연속으로 쌓으면 결국 하나의 affine layer와 다르지 않다.

(XW1+b1)W2+b2=X(W1W2)+(b1W2+b2)(XW_1 + b_1)W_2 + b_2 = X(W_1W_2) + (b_1W_2 + b_2)

즉 중간에 nonlinearity가 없으면 여러 layer를 쌓아도 표현력이 크게 늘어나지 않는다.
ReLU를 사이에 넣어야 입력 공간을 비선형적으로 변환할 수 있고, linear classifier보다 더 복잡한 decision boundary를 만들 수 있다.

L2 regularization의 0.5 계수

이번 구현에서는 regularization loss에 0.50.5를 곱한다.

L=Ldata+12λ(W12+W22)L = L_{\text{data}} + \frac{1}{2}\lambda \left( \sum W_1^2 + \sum W_2^2 \right)

이렇게 두면 weight에 대한 gradient가 깔끔해진다.

W12λW2=λW\frac{\partial}{\partial W} \frac{1}{2}\lambda W^2 = \lambda W

그래서 backward pass에서 다음처럼 더한다.

dW1 += self.reg * W1
dW2 += self.reg * W2

이전 softmax 과제에서는 regularization을 λW2\lambda \sum W^2 형태로 쓰면 gradient가 2λW2\lambda W가 되었는데, 여기서는 0.50.5를 포함했기 때문에 λW\lambda W가 된다.

Solver로 TwoLayerNet 학습

layer 구현이 끝나면 Solver를 이용해 TwoLayerNet을 학습시킨다.
이번 과제에서는 hidden layer 크기를 50으로 두고, SGD를 사용해서 validation accuracy가 대략 36% 이상 나오도록 설정했다.

input_size = 32 * 32 * 3
hidden_size = 50
num_classes = 10
model = TwoLayerNet(input_size, hidden_size, num_classes)
solver = None

solver = Solver(
    model,
    data,
    update_rule="sgd",
    optim_config={
        "learning_rate": 1e-3,
    },
    lr_decay=0.95,
    num_epochs=5,
    batch_size=200,
    print_every=100,
)
solver.train()

학습 결과는 다음과 같다.

two-layer-net-training

Training loss는 초반에 빠르게 내려가고, 이후에는 완만하게 감소한다.
Accuracy는 epoch이 진행되면서 train과 validation 모두 올라가며, 마지막에는 validation accuracy가 약 0.49 정도까지 나온다.

train accuracy가 validation accuracy보다 조금 더 높지만, 둘의 차이가 아주 크지는 않다.
따라서 이 설정에서는 모델이 어느 정도 학습되고 있고, 심한 overfitting은 아직 아니라고 볼 수 있다.

Activation function과 gradient flow

이번 과제에서는 ReLU만 구현했지만, neural network에서는 여러 activation function을 사용할 수 있다.
여기서 중요한 문제 중 하나는 backpropagation 중 gradient가 0 또는 0에 가까워지는 경우이다.

Sigmoid는 입력의 절댓값이 커질 때 gradient가 거의 0에 가까워진다.

σ(x)=11+ex\sigma(x) = \frac{1}{1+e^{-x}} σ(x)=σ(x)(1σ(x))\sigma'(x) = \sigma(x)(1-\sigma(x))

Sigmoid의 출력값은 항상 0과 1 사이에 있다.

0<σ(x)<10 < \sigma(x) < 1

xx \rightarrow \infty이면 σ(x)1\sigma(x) \rightarrow 1이고, xx \rightarrow -\infty이면 σ(x)0\sigma(x) \rightarrow 0이다.
다만 유한한 입력 xx에 대해서는 정확히 0이나 1이 되지는 않는다.

Sigmoid의 local gradient 범위는 다음과 같다.

0<σ(x)140 < \sigma'(x) \le \frac{1}{4}

최댓값은 x=0x=0일 때 나온다.

σ(0)=12,σ(0)=12(112)=14\sigma(0)=\frac{1}{2}, \quad \sigma'(0)=\frac{1}{2}\left(1-\frac{1}{2}\right)=\frac{1}{4}

xx가 아주 큰 양수이면 σ(x)\sigma(x)가 1에 가까워지고, 아주 작은 음수이면 0에 가까워진다.
두 경우 모두 σ(x)\sigma'(x)가 0에 가까워지기 때문에 gradient flow가 약해진다.

ReLU는 입력이 음수일 때 gradient가 정확히 0이다.

ReLU(x)=max(0,x)\text{ReLU}(x) = \max(0,x) ReLU(x)x={1x>00x0\frac{\partial \text{ReLU}(x)}{\partial x} = \begin{cases} 1 & x > 0 \\ 0 & x \le 0 \end{cases}

따라서 어떤 neuron이 계속 음수 영역에 머물면 gradient가 전달되지 않는 문제가 생길 수 있다.
이 현상을 보통 dead ReLU 문제라고 부른다.

반면 Leaky ReLU는 음수 영역에서도 작은 기울기를 남긴다.

LeakyReLU(x)={xx>0αxx0\text{LeakyReLU}(x) = \begin{cases} x & x > 0 \\ \alpha x & x \le 0 \end{cases}

여기서 α\alpha는 보통 0보다 작은 값이 아니라, 0보다 큰 작은 양수이다.
그래서 음수 입력에서도 gradient가 완전히 0이 되지는 않는다.

정리하면, gradient flow 문제가 특히 나타나는 activation function은 Sigmoid와 ReLU이다.

  • Sigmoid: 입력의 절댓값이 클 때 saturation 때문에 gradient가 거의 0
  • ReLU: 입력이 음수일 때 gradient가 0
  • Leaky ReLU: 음수 영역에서도 작은 기울기가 있어 완전한 0 gradient를 피함

Training accuracy와 validation accuracy gap 줄이기

Neural network classifier를 학습하면 train accuracy는 높은데 validation 또는 test accuracy가 낮을 수 있다.
이 경우 모델이 훈련 데이터에 너무 맞춰져서 새로운 데이터에 잘 일반화하지 못하는 상황일 수 있다.

gap을 줄이는 방법으로는 다음이 있다.

  1. 더 큰 dataset으로 학습한다.
  2. regularization strength를 증가시킨다.

더 많은 데이터는 모델이 특정 훈련 샘플에만 맞춰지는 것을 줄여준다.
Regularization을 키우면 weight가 지나치게 커지는 것을 막고, 모델이 더 단순한 방향을 선호하게 만든다.

반대로 hidden unit을 무작정 늘리는 것은 모델 capacity를 키우는 일이므로, 이미 overfitting이 있는 상황에서는 gap을 더 키울 수도 있다.
따라서 train과 validation/test 사이의 성능 차이를 줄이는 목적이라면 data 증가와 regularization 증가가 더 직접적인 선택이다.

Cache의 의미

Forward pass에서 cache를 저장하는 이유는 backward pass에서 local gradient를 계산하려면 forward 때의 입력이 필요하기 때문이다.

  • affine backward는 x, w, b가 필요함
  • ReLU backward는 forward input x가 필요함
  • softmax backward는 probability와 정답 label을 이용함

따라서 layer를 모듈화할 때는 forward에서 out, cache를 반환하고, backward에서 dout, cache를 받아 dx, dw, db를 반환하는 패턴이 반복된다.

shape 확인

CIFAR-10 기준으로 입력 이미지는 다음 shape을 가진다.

32×32×3=307232 \times 32 \times 3 = 3072

기본 설정이 hidden_dim=100, num_classes=10이면 parameter shape은 다음과 같다.

W1R3072×100W_1 \in \mathbb{R}^{3072 \times 100} b1R100b_1 \in \mathbb{R}^{100} W2R100×10W_2 \in \mathbb{R}^{100 \times 10} b2R10b_2 \in \mathbb{R}^{10}

batch size가 NN일 때 output score는 다음 shape이다.

scoresRN×10scores \in \mathbb{R}^{N \times 10}

결국 이 과제의 핵심은 layer별 forward/backward를 정확히 구현하고, 각 단계에서 shape이 어떻게 이어지는지 계속 확인하는 것이다.

Backward pass에서 gradient shape

역전파에서 어떤 값에 대한 gradient는 그 값 자체와 같은 shape을 가진다.
Lx\frac{\partial L}{\partial x}xx와 같은 shape이고, LW\frac{\partial L}{\partial W}WW와 같은 shape이며, Lb\frac{\partial L}{\partial b}bb와 같은 shape이다.

Affine layer를 예로 들면 다음과 같다.

xRN×D,WRD×M,bRM,outRN×Mx \in \mathbb{R}^{N \times D}, \quad W \in \mathbb{R}^{D \times M}, \quad b \in \mathbb{R}^{M}, \quad out \in \mathbb{R}^{N \times M}

따라서 backward pass에서 나오는 gradient shape은 다음처럼 맞아야 한다.

doutRN×M,dxRN×D,dWRD×M,dbRMdout \in \mathbb{R}^{N \times M}, \quad dx \in \mathbb{R}^{N \times D}, \quad dW \in \mathbb{R}^{D \times M}, \quad db \in \mathbb{R}^{M}

여기서 dout은 forward의 out과 같은 shape이고, dx, dW, db는 각각 forward에서 사용한 x, W, b와 같은 shape이다.
그래서 backward 구현을 확인할 때는 수식 자체도 중요하지만, gradient가 원래 대상 변수와 같은 shape으로 돌아오는지 보는 것이 가장 빠른 디버깅 기준이 된다.