Assignment 1-5: Training a fully connected network

ASSIGNMENT 글 목록
목차

Overview

이번 과제에서는 임의의 개수의 hidden layer를 갖는 FullyConnectedNet을 구현하고, 여러 optimizer를 직접 구현해본다.
앞에서 만든 TwoLayerNet이 하나의 hidden layer만 갖는 구조였다면, 여기서는 hidden layer 개수를 리스트로 받아 더 깊은 fully-connected network를 구성한다.

기본 구조는 다음과 같다.

{affineReLU}L1affinesoftmax\{\text{affine} \rightarrow \text{ReLU}\}^{L-1} \rightarrow \text{affine} \rightarrow \text{softmax}

즉 마지막 layer를 제외한 모든 layer는 affine transformation 뒤에 ReLU를 통과하고, 마지막 layer에서는 class score만 계산한 뒤 softmax loss로 학습한다.

이번 글에서 정리할 구현 포인트는 다음과 같다.

  • self.params에 layer별 weight와 bias 저장
  • 여러 layer에 대한 forward pass
  • softmax loss와 L2 regularization
  • 역순으로 진행되는 backward pass
  • SGD Momentum, RMSProp, Adam optimizer 구현

핵심 아이디어

깊은 fully-connected network는 affine-ReLU block을 여러 번 쌓은 구조이고, 학습은 각 layer의 cache를 저장해두었다가 backward pass에서 역순으로 gradient를 계산하는 방식으로 진행된다.

layer가 LL개인 network에서 parameter는 다음처럼 저장한다.

W1,b1,W2,b2,,WL,bLW_1, b_1, W_2, b_2, \dots, W_L, b_L

hidden dimension을 hidden_dims = [H_1, H_2, ..., H_{L-1}]라고 하면 전체 dimension 흐름은 다음과 같다.

DH1H2HL1CD \rightarrow H_1 \rightarrow H_2 \rightarrow \cdots \rightarrow H_{L-1} \rightarrow C

여기서 DD는 입력 차원이고, CC는 class 개수이다.
각 weight의 shape은 인접한 두 layer dimension으로 결정된다.

WiRdi1×di,biRdiW_i \in \mathbb{R}^{d_{i-1} \times d_i}, \qquad b_i \in \mathbb{R}^{d_i}

loss는 data loss와 regularization loss를 함께 사용한다.

L=Ldata+12λi=1LWi22L = L_{\text{data}} + \frac{1}{2}\lambda \sum_{i=1}^{L} \lVert W_i \rVert_2^2

여기서 12\frac{1}{2}를 붙이는 이유는 gradient를 단순하게 만들기 위해서이다.

Wi(12λWi22)=λWi\frac{\partial}{\partial W_i} \left( \frac{1}{2}\lambda \lVert W_i \rVert_2^2 \right) = \lambda W_i

따라서 각 weight gradient에는 data loss에서 온 gradient에 λWi\lambda W_i만 더해주면 된다.

구현

Parameter 초기화

FullyConnectedNet은 hidden layer 개수가 고정되어 있지 않으므로, 먼저 전체 layer dimension을 하나의 리스트로 만든다.

layer_dims = [input_dim] + hidden_dims + [num_classes]

for i in range(1, self.num_layers + 1):
    fan_in = layer_dims[i - 1]
    fan_out = layer_dims[i]

    self.params[f"W{i}"] = weight_scale * np.random.randn(fan_in, fan_out)
    self.params[f"b{i}"] = np.zeros(fan_out)

    if self.normalization is not None and i < self.num_layers:
        self.params[f"gamma{i}"] = np.ones(fan_out)
        self.params[f"beta{i}"] = np.zeros(fan_out)

weight는 평균이 0이고 표준편차가 weight_scale인 normal distribution에서 초기화한다.
bias는 처음에는 특별히 어느 class나 neuron 쪽으로 치우치지 않도록 0으로 초기화한다.

gamma, beta는 batch normalization이나 layer normalization을 사용할 때 필요한 scale과 shift parameter이다.
이번 기본 구현의 핵심은 affine-ReLU 반복 구조이지만, normalization 옵션까지 고려하면 마지막 layer를 제외한 hidden layer마다 gamma, beta가 필요하다.

Forward pass

forward pass에서는 마지막 layer 전까지 affine_forwardrelu_forward를 반복한다.
각 layer의 backward pass에서 다시 써야 하므로 cache를 layer 번호별로 저장한다.

out, caches, L = X, {}, self.num_layers

for i in range(1, L):
    W, b = self.params[f"W{i}"], self.params[f"b{i}"]

    out, affine_cache = affine_forward(out, W, b)
    out, relu_cache = relu_forward(out)

    caches[i] = {
        "affine": affine_cache,
        "relu": relu_cache,
    }

W, b = self.params[f"W{L}"], self.params[f"b{L}"]
out, affine_cache = affine_forward(out, W, b)

caches[L] = {
    "affine": affine_cache,
}

scores = out

마지막 layer에 ReLU를 붙이지 않는 이유는 최종 출력이 class score여야 하기 때문이다.
score는 양수일 필요도 없고, 확률일 필요도 없다. softmax loss에서 이 score를 확률 분포로 바꿔 사용한다.

test mode에서는 label y가 없으므로 loss를 계산하지 않고 scores만 반환한다.

Backward pass

backward pass는 forward pass의 반대 순서로 진행한다.
먼저 softmax loss에서 scores에 대한 gradient인 dscore를 얻고, 마지막 affine layer부터 역전파를 시작한다.

loss, dscore = softmax_loss(scores, y)

for i in range(1, L + 1):
    regW = self.params[f"W{i}"]
    loss += 0.5 * self.reg * np.sum(regW * regW)

dx, dw, db = affine_backward(
    dscore,
    caches[L]["affine"],
)
grads[f"W{L}"] = dw + self.reg * self.params[f"W{L}"]
grads[f"b{L}"] = db

for i in range(L - 1, 0, -1):
    relu_cache = caches[i]["relu"]
    dx = relu_backward(dx, relu_cache)

    affine_cache = caches[i]["affine"]
    dx, dw, db = affine_backward(
        dx,
        affine_cache,
    )

    grads[f"W{i}"] = dw + self.reg * self.params[f"W{i}"]
    grads[f"b{i}"] = db

그리고 backward pass에서 gradient의 shape은 대응되는 forward parameter의 shape과 같아야 한다.

LWiRdi1×di\frac{\partial L}{\partial W_i} \in \mathbb{R}^{d_{i-1} \times d_i} LbiRdi\frac{\partial L}{\partial b_i} \in \mathbb{R}^{d_i}

grads["W1"]self.params["W1"]과 같은 shape이고, grads["b1"]self.params["b1"]과 같은 shape이어야 한다.
shape이 맞지 않으면 chain rule 자체가 잘못 연결되었거나, batch 방향 합산이 빠졌을 가능성이 크다.

SGD Momentum

기본 SGD는 현재 gradient만 보고 parameter를 업데이트한다.

wt+1=wtηgtw_{t+1} = w_t - \eta g_t

Momentum은 이전 update 방향을 velocity로 기억한다.

def sgd_momentum(w, dw, config=None):
    if config is None:
        config = {}
    config.setdefault("learning_rate", 1e-2)
    config.setdefault("momentum", 0.9)
    v = config.get("velocity", np.zeros_like(w))

    p = config["momentum"]
    lr = config["learning_rate"]

    v = p * v - lr * dw
    next_w = w + v

    config["velocity"] = v

    return next_w, config

수식으로 쓰면 다음과 같다.

vt=μvt1ηgtv_t = \mu v_{t-1} - \eta g_t wt=wt1+vtw_t = w_{t-1} + v_t

여기서 μ\mu는 momentum 계수이고, η\eta는 learning rate이다.

RMSProp

RMSProp은 각 parameter 방향마다 gradient가 평소 얼마나 컸는지를 추적한다.
이를 위해 squared gradient의 exponential moving average를 사용한다.

def rmsprop(w, dw, config=None):
    if config is None:
        config = {}
    config.setdefault("learning_rate", 1e-2)
    config.setdefault("decay_rate", 0.99)
    config.setdefault("epsilon", 1e-8)
    config.setdefault("cache", np.zeros_like(w))

    decay = config["decay_rate"]
    cache = config["cache"]
    e = config["epsilon"]
    lr = config["learning_rate"]

    cache = decay * cache + (1 - decay) * (dw * dw)
    next_w = w - lr * (dw / (np.sqrt(cache) + e))

    config["cache"] = cache

    return next_w, config

수식으로 쓰면 다음과 같다.

cachet=ρcachet1+(1ρ)gt2\text{cache}_t = \rho \cdot \text{cache}_{t-1} + (1-\rho)g_t^2 wt=wt1ηgtcachet+ϵw_t = w_{t-1} - \eta \frac{g_t} {\sqrt{\text{cache}_t}+\epsilon}

gradient가 큰 방향은 cache도 커지기 때문에 update가 작아진다.
반대로 gradient가 작은 방향은 cache가 작아서 상대적으로 더 크게 움직일 수 있다.

Adam

Adam은 Momentum과 RMSProp의 아이디어를 합친 optimizer로 볼 수 있다.
first moment인 mtm_t는 gradient의 moving average이고, second moment인 vtv_t는 squared gradient의 moving average이다.

def adam(w, dw, config=None):
    if config is None:
        config = {}
    config.setdefault("learning_rate", 1e-3)
    config.setdefault("beta1", 0.9)
    config.setdefault("beta2", 0.999)
    config.setdefault("epsilon", 1e-8)
    config.setdefault("m", np.zeros_like(w))
    config.setdefault("v", np.zeros_like(w))
    config.setdefault("t", 0)

    lr = config["learning_rate"]
    beta1 = config["beta1"]
    beta2 = config["beta2"]
    e = config["epsilon"]
    m = config["m"]
    v = config["v"]

    config["t"] += 1
    t = config["t"]

    m = beta1 * m + (1 - beta1) * dw
    v = beta2 * v + (1 - beta2) * dw * dw

    m_hat = m / (1 - beta1 ** t)
    v_hat = v / (1 - beta2 ** t)

    next_w = w - lr * (m_hat / (np.sqrt(v_hat) + e))

    config["m"] = m
    config["v"] = v

    return next_w, config

Adam의 update는 다음처럼 정리할 수 있다.

mt=β1mt1+(1β1)gtm_t = \beta_1 m_{t-1} + (1-\beta_1)g_t vt=β2vt1+(1β2)gt2v_t = \beta_2 v_{t-1} + (1-\beta_2)g_t^2 m^t=mt1β1t,v^t=vt1β2t\hat m_t = \frac{m_t}{1-\beta_1^t}, \qquad \hat v_t = \frac{v_t}{1-\beta_2^t} wt=wt1ηm^tv^t+ϵw_t = w_{t-1} - \eta \frac{\hat m_t} {\sqrt{\hat v_t}+\epsilon}

Adam은 방향이 일관된 gradient는 momentum처럼 누적하고, 특정 parameter 방향의 gradient scale은 RMSProp처럼 보정한다.
그래서 기본 SGD보다 loss landscape의 모양에 더 적응적으로 반응한다.

Best FullyConnectedNet 학습

마지막에는 CIFAR-10에서 가능한 좋은 FullyConnectedNet을 학습해 best_model에 저장한다.
이번 실험에서는 hidden layer를 4개 사용했고, 각 hidden layer의 neuron 수는 100으로 두었다.

best_model = None

data = {
    "X_train": data["X_train"],
    "y_train": data["y_train"],
    "X_val": data["X_val"],
    "y_val": data["y_val"],
    "X_test": data["X_test"],
    "y_test": data["y_test"],
}

weight_scale = 10e-2
learning_rate = 1e-3

model = FullyConnectedNet(
    [100, 100, 100, 100],
    weight_scale=weight_scale,
    dtype=np.float64,
)

solver = Solver(
    model,
    data,
    print_every=100,
    num_epochs=10,
    batch_size=25,
    update_rule="adam",
    optim_config={
        "learning_rate": learning_rate,
    },
)

solver.train()

best_model = solver.model

여기서 weight_scale = 10e-20.10.1을 의미한다.
앞에서 깊은 network가 initialization scale에 민감하다는 것을 확인했기 때문에, weight_scale은 단순한 초기값이 아니라 중요한 하이퍼파라미터로 봐야 한다.

optimizer는 adam을 사용했다.
Adam은 gradient의 방향에 대한 moving average와 squared gradient의 moving average를 함께 사용하므로, 단순 SGD보다 학습 초반을 비교적 안정적으로 가져갈 수 있다.

학습 중 epoch별 accuracy는 다음과 같았다.

EpochTrain AccuracyValidation Accuracy
00.1130.105
10.3060.321
20.4060.398
30.4350.430
40.4790.440
50.4820.441
60.4790.474
70.4980.440
80.4780.463
90.5360.483
100.5410.486

초기 loss는 매우 컸지만, iteration이 진행되면서 빠르게 감소했다.

이후에는 minibatch 단위의 noise 때문에 loss가 계속 매끄럽게 감소하기보다는 1.x1.x 근처에서 흔들리면서 내려간다.

(Iteration 1 / 19600) loss: 200.674093
(Iteration 101 / 19600) loss: 15.340574
(Iteration 201 / 19600) loss: 3.077042
(Iteration 301 / 19600) loss: 3.328555
...
(Epoch 10 / 10) train acc: 0.541000; val_acc: 0.486000

loss curve는 다음과 같다.

fully-connected-net-training-loss

최종적으로 validation set과 test set에서 다음 accuracy를 얻었다.

y_test_pred = np.argmax(best_model.loss(data["X_test"]), axis=1)
y_val_pred = np.argmax(best_model.loss(data["X_val"]), axis=1)

print("Validation set accuracy: ", (y_val_pred == data["y_val"]).mean())
print("Test set accuracy: ", (y_test_pred == data["y_test"]).mean())
Validation set accuracy:  0.486
Test set accuracy:  0.481

training accuracy가 54.1%54.1\%, validation accuracy가 48.6%48.6\%, test accuracy가 48.1%48.1\%이므로, train과 validation 사이에 약간의 gap은 있지만 크게 벌어지지는 않았다.

추가 개념들

깊은 network와 initialization scale

과제에서 3-layer network와 5-layer network를 학습해보면, 5-layer network가 initialization scale에 더 민감하게 느껴진다.

layer가 많아질수록 activation과 gradient는 더 많은 행렬곱과 ReLU를 통과한다.
따라서 초기 weight scale이 조금만 맞지 않아도 그 영향이 layer를 지나며 누적된다.

weight가 너무 작으면 activation과 gradient의 variance가 layer를 지나며 줄어들 수 있다.
이 경우 신호가 약해지고 학습이 느려진다.

반대로 weight가 너무 크면 activation과 gradient의 variance가 layer를 지나며 커질 수 있다.
이 경우 score가 불안정해지고 기울기가 폭발하거나 수치적 불안정성이 발생할 수 있다.

즉 5-layer network는 안정적으로 학습되는 weight_scale 범위가 3-layer network보다 더 좁다.

왜 gradient를 제곱하는가

RMSProp과 Adam은 각 parameter 방향에서 gradient가 평소 얼마나 큰지를 추적한다.

cachet=ρcachet1+(1ρ)gt2\text{cache}_t = \rho \cdot \text{cache}_{t-1} + (1-\rho)g_t^2

여기서 gradient를 그대로 평균내면 부호가 반대인 값들이 상쇄될 수 있다.

g=+10,10,+10,10g = +10, -10, +10, -10 1010+10104=0\frac{10 - 10 + 10 - 10}{4} = 0

하지만 이 경우 gradient의 크기는 계속 10으로 매우 크다.
제곱하면 부호가 사라지고 크기 정보가 남는다.

g2=100,100,100,100g^2 = 100, 100, 100, 100

즉 제곱은 gradient의 부호를 제거하고, 각 parameter 방향의 gradient magnitude를 측정하기 위한 장치이다.
여기서 cache는 정확한 variance라기보다는 squared gradient의 exponential moving average로 보는 것이 자연스럽다.

왜 제곱근으로 나누는가

cache에는 gradient를 제곱한 값이 들어간다.

cacheE[g2]\text{cache} \approx E[g^2]

따라서 cache의 단위는 gradient의 제곱 단위이다.
제곱근을 취하면 다시 원래 gradient와 비슷한 scale로 돌아온다.

cacheRMS(g)\sqrt{\text{cache}} \approx \text{RMS}(g)

예를 들어 g=10g=10이고 cache100\text{cache}\approx100이면 다음과 같다.

gcache=10100=1\frac{g}{\sqrt{\text{cache}}} = \frac{10}{\sqrt{100}} = 1

제곱근 없이 cache로 바로 나누면 대략 다음처럼 된다.

gg2=1g\frac{g}{g^2} = \frac{1}{g}

이렇게 되면 보정이 지나치게 강해질 수 있다.
따라서 제곱근은 제곱된 gradient scale을 원래 scale에 가깝게 되돌리고, 각 좌표의 update 크기를 적절히 정규화하는 역할을 한다.

Momentum은 왜 진동을 줄이는가

손실함수가 좁고 긴 골짜기 모양이면, 가파른 방향에서는 gradient 부호가 자주 바뀌고, 최적점으로 향하는 방향에서는 gradient 부호가 비교적 일정하다.

진동 방향의 gradient가 다음처럼 반복된다고 하자.

+g,g,+g,g+g, -g, +g, -g

이전 velocity와 현재 gradient가 서로 반대 방향이 되기 쉽기 때문에 누적 과정에서 상쇄된다.

반면 진행 방향의 gradient는 다음처럼 비슷한 방향으로 반복될 수 있다.

+g,+g,+g,+g+g, +g, +g, +g

이 방향은 velocity에 계속 누적된다.
결국 momentum은 방향이 자주 바뀌는 축의 update는 평균화해서 줄이고, 방향이 일관된 축의 update는 키운다.

Adam에서 bias correction이 필요한 이유

Adam의 moving average는 0에서 시작한다.

m0=0,v0=0m_0 = 0, \qquad v_0 = 0

예를 들어 β1=0.9\beta_1=0.9, g1=10g_1=10이면 첫 번째 moment는 다음과 같다.

m1=0.90+0.110=1m_1 = 0.9 \cdot 0 + 0.1 \cdot 10 = 1

실제 gradient는 10인데 m1m_1은 1이다.
초기값 0이 크게 반영되었기 때문에 초반 moving average가 실제보다 작게 추정된다.

초반에는 실제 gradient들에 붙은 전체 가중치 합이 1이 아니라 다음 값이다.

1β1t1-\beta_1^t

그래서 다음처럼 나누어 가중치 합을 다시 1에 가깝게 맞춘다.

m^t=mt1β1t\hat m_t = \frac{m_t}{1-\beta_1^t} v^t=vt1β2t\hat v_t = \frac{v_t}{1-\beta_2^t}

같은 예시에서 m1=1m_1=1, β1=0.9\beta_1=0.9이면 다음과 같다.

m^1=110.9=10\hat m_1 = \frac{1}{1-0.9} = 10

bias correction은 초기값 0 때문에 생긴 과소평가를 보정하는 과정이다.
0을 직접 제거하는 것이 아니라, 실제 gradient에 배분된 가중치 합을 다시 맞추는 과정에 가깝다.
tt가 커지면 βt\beta^t가 0에 가까워지므로 보정 효과도 점점 작아진다.

AdaGrad update가 점점 작아지는 이유

AdaGrad는 squared gradient를 계속 누적한다.

cache += dw**2
w += -learning_rate * dw / (np.sqrt(cache) + eps)

수식으로 쓰면 다음과 같다.

cachet=cachet1+gt2\text{cache}_t = \text{cache}_{t-1} + g_t^2

squared gradient는 항상 0 이상이므로 cache는 줄어들지 않고 계속 커진다.
특히 학습 초기에 큰 gradient가 나오면 그 값이 계속 누적되어 남는다.

AdaGrad의 update는 다음과 같다.

Δwt=ηgtcachet+ϵ\Delta w_t = - \eta \frac{g_t} {\sqrt{\text{cache}_t}+\epsilon}

cache가 커질수록 분모가 커지고, effective learning rate는 계속 작아진다.
그래서 학습이 진행될수록 update가 매우 작아지고 learning이 느려질 수 있다.

Adam은 이 문제를 그대로 갖지는 않는다.
Adam은 squared gradient를 단순 누적하지 않고 exponential moving average로 관리한다.

vt=β2vt1+(1β2)gt2v_t = \beta_2 v_{t-1} + (1-\beta_2)g_t^2

오래된 squared gradient의 영향은 β2\beta_2가 반복해서 곱해지면서 점점 줄어든다.
따라서 Adam은 AdaGrad처럼 squared gradient를 무한히 누적해서 effective learning rate가 계속 작아지는 구조는 아니다.

한 줄 요약

  • 제곱은 gradient의 부호를 없애고 크기를 측정한다.
  • 제곱근은 제곱된 값을 원래 gradient scale에 가깝게 되돌린다.
  • bias correction은 초기값 0 때문에 작게 계산된 moving average를 보정한다.
  • Momentum은 진동 방향은 상쇄하고, 일관된 방향은 누적한다.