LSTM의 원리와 수식 계산

LSTM은 RNN의 핵심을 이루는 모델이다. 그러나, 구체적인 구현에 대해 잘못 이해하고 있는 경우가 많다. 원리를 정확히 이해하는 일은 매우 중요한 만큼 여기서는 원리와 수식을 통해 직접 계산해보고 결과값을 검증해보도록 한다.

2018년 5월 30일 초안 작성

LSTM

LSTM을 소개하는 가장 훌륭한 자료로 Chris Olah의 글이 손꼽힌다. 벌써 3년전 글 임에도 복잡한 LSTM 구조를 이해하기 쉽도록 탁월하게 표현해냈다.

1

LSTM 셀을 떼어내 레이블을 부여해 정리2하면 아래와 같다.

2

맨 위에 컨베이너 벨트처럼 흐르는 \(C\)값이 cell state이며, LSTM은 이 cell state를 보호하고 컨트롤 하기 위한 세 가지 게이트: forget, input, output gate를 통해 vanishing gradient를 방지하고 그래디언트가 효과적으로 흐를 수 있게 한다.

  • forget gate \(f_t\)는 말그대로 ‘과거 정보를 잊기’위한 게이트다. 시그모이드 함수의 출력 범위는 0 ~ 1 이기 때문에 그 값이 0이라면 이전 상태의 정보는 잊고, 1이라면 이전 상태의 정보를 온전히 기억하게 된다.3
  • input gate \(i_t\)는 ‘현재 정보를 기억하기’위한 게이트다. 이 값은 시그모이드 이므로 0 ~ 1 이지만 hadamard product를 하는 \(\tilde{C}_t\)는 hyperbolic tangent 결과이므로 -1 ~ 1 이 된다.3 따라서 결과는 음수가 될 수도 있다.
  • output gate \(o_t\)는 최종 결과 \(h_t\)를 위한 게이트이며, cell state의 hyperbolic tangent를 hadamard product한 값이 LSTM의 최종 결과가 된다.

(케라스 창시자에게 배우는 딥러닝, 2017)

프랑소와 숄레는 LSTM을 설명하면서 상태 정보에 대한 새로운 이동 상태 계산으로 표현했다.

(Neural networks and deep learning by Aurélien Géron, 2018)

『핸즈온 머신러닝』의 저자 오렐리앙 제롱이 이후에 딥러닝 책을 eBook으로 출간했는데, 여기에서 LSTM의 내부 구조를 FC 모음에 Forget gate, Input gate, Output gate의 게이트 구조로 보다 직관적으로 표현해냈다.

수식 구현

이번에는 수식을 살펴보고 직접 구현해보도록 한다. 여기서는 위키피디어에 등록된 LSTM with a forget gate의 수식을 기준으로 구현해본다. \(x_t\)와 \(h_{t-1}\)에 서로 다른 가중치를 부여하며, \(\tilde{C}_t\)는 따로 지정하지 않고 \(c_t\) 수식에 inline 형태로 삽입한다.

수식을 코드로 동일하게 구현하면 아래와 같다.

ft = sigmoid(np.dot(xt, Wf) + np.dot(ht_1, Uf) + bf)  # forget gate
it = sigmoid(np.dot(xt, Wi) + np.dot(ht_1, Ui) + bi)  # input gate
ot = sigmoid(np.dot(xt, Wo) + np.dot(ht_1, Uo) + bo)  # output gate
Ct = ft * Ct_1 + it * np.tanh(np.dot(xt, Wc) + np.dot(ht_1, Uc) + bc)
ht = ot * np.tanh(Ct)

cell state \(C_t\)와 hidden state \(h_t\)는 아래와 같이 이 전의 값을 저장해 다음 스텝에서 사용한다.

ht_1 = ht  # hidden state, previous memory state
Ct_1 = Ct  # cell state, previous carry state

Keras

이제 직접 NumPy로 계산해 Keras의 결과와 비교해보도록 한다. 백엔드는 TensorFlow를 사용했다. 참고로 Keras의 LSTM 구현은 tf.nn.rnn_cell.BasicLSTMCell은 물론 tf.nn.rnn_cell.RNNCell조차 사용하지 않으며, RNN 구조를 Keras 팩키지 내에서 직접 구현하는 형태로 디자인 되어 있다.

model = Sequential()
model.add(LSTM(5, input_shape=(10, 3)))

model.compile(loss='MSE',
              optimizer='SGD',
              metrics=['accuracy'])

LSTM 수식을 복잡하게 구현했던 것과 달리 Keras에서 LSTM을 구현하는 방법은 매우 직관적이고 쉽다. 이 때문에 Keras는 연구자들에게 최근 매우 인기가 높다. 여기서는 레이어를 5개로 정하고 계산 결과를 비교해보도록 한다.

가중치 추출

우리가 계산하려는 NumPy 구현은 학습을 구현하지 않았기 때문에 Keras에서 학습한 가중치를 그대로 사용하도록 한다. 따라서, 가중치를 아래와 같은 형태로 추출해낸다.

names = [weight.name for layer in model.layers for weight in layer.weights]
weights = model.get_weights()

for name, weight in zip(names, weights):
    print(name, weight.shape)
    print(weight)

    layer_type = name.split('/')[1]
    if layer_type == 'kernel:0':
        kernel_0 = weight
    if layer_type == 'recurrent_kernel:0':
        recurrent_kernel_0 = weight
    elif layer_type == 'bias:0':
        bias_0 = weight

    print()

여기서는 출력을 겸했지만 핵심은 kernel_0, recurrent_kernel_0, bias_0 가중치를 추출한 것이다. 가중치는 수식 코드에 적용하기 위해 변수로 따로 맵핑한다. 각각의 shape는 주석에 표기했으며, 여기에 학습된 가중치로 디코딩 하여 결과를 맞춰볼 것이다.

units = 5  # LSTM layers

# (3, 20) embedding dims, units * 4
Wi = kernel_0[:, 0:units]
Wf = kernel_0[:, units:2 * units]
Wc = kernel_0[:, 2 * units:3 * units]
Wo = kernel_0[:, 3 * units:]

# (5, 20) units, units * 4
Ui = recurrent_kernel_0[:, 0:units]
Uf = recurrent_kernel_0[:, units:2 * units]
Uc = recurrent_kernel_0[:, 2 * units:3 * units]
Uo = recurrent_kernel_0[:, 3 * units:]

# (20,) units * 4
bi = bias_0[0:units]
bf = bias_0[units:2 * units]
bc = bias_0[2 * units:3 * units]
bo = bias_0[3 * units:]

Hard Sigmoid

Keras는 특이하게도 activation으로 sigmoid 대신 hard sigmoid를 디폴트로 사용한다. 처음에 이 점을 알아차리지 못해 한동안 값을 맞추지 못했고, 결국 SO에 질문을 남겼다. 거의 반나절을 고생했는데 놀랍게도 하루만에 답변이 달렸고, Keras의 LSTM activation 디폴트가 hard sigmoid 라는 점을 바로 알아차릴 수 있었다.

def hard_sigmoid(x):
    return np.clip(0.2 * x + 0.5, 0, 1)

그렇다면 왜 원래의 수식과 달리 hard sigmoid를 디폴트로 했을까. 정확한 이유는 찾지 못했지만 같은 팀 동료의 추측에 따르면 속도 개선을 위해 그랬을 것이라 한다. 실제로 sigmoid는 지수를 사용하는 수식이라 연산 비용이 많이 들지만 hard sigmoid는 min, max 처리에 불과해 속도가 매우 빠르면서도 선형 근사linearly approximated로 큰 차이 없이 비슷한 결과를 만들어 낼 수 있다.

이제 hard sigmoid로 수식을 다시 작성한 최종 구현은 아래와 같다.

ft = hard_sigmoid(np.dot(xt, Wf) + np.dot(ht_1, Uf) + bf)  # forget gate
it = hard_sigmoid(np.dot(xt, Wi) + np.dot(ht_1, Ui) + bi)  # input gate
ot = hard_sigmoid(np.dot(xt, Wo) + np.dot(ht_1, Uo) + bo)  # output gate
Ct = ft * Ct_1 + it * np.tanh(np.dot(xt, Wc) + np.dot(ht_1, Uc) + bc)
ht = ot * np.tanh(Ct)

계산 결과 비교

\(x\)값이 아래와 같을때,

x = np.array([[
    [0, 0, 0],
    [0, 0, 0],
    [0, 0, 0],
    [0, 0, 0],
    [1.4, 1.5, 1.2],
    [1.9, 1.1, 1.2],
    [1.7, 1.4, 1.2],
    [1.5, 1.3, 1.2],
    [1.5, 1.3, 1.2],
    [0, 0.1, 0.2],
]])  # (None, 10, 3)

Keras 모델의 계산 결과는 아래와 같다.

intermediate_layer_model = Model(inputs=model.input,
                                 outputs=model.output)
output = intermediate_layer_model.predict(x[:1])
print("Keras:", output)
--
Keras: [[ 0.71196353  0.6715399   0.7643083   0.6299607  -0.02251862]]

NumPy로 계산한 hidden state \(h_t\)의 출력값은 아래와 같다.

0 [[0.1038093  0.15435632 0.15512641 0.08695523 0.04077479]]
1 [[0.21136876 0.27448883 0.27900079 0.17392188 0.05238609]]
2 [[0.31456114 0.36370756 0.37507162 0.25360612 0.05279399]]
3 [[0.40547204 0.42905395 0.44804183 0.32070752 0.05001786]]
4 [[0.50535968 0.46595714 0.83891787 0.63718401 0.04864688]]
5 [[ 0.62052159  0.50566436  0.92395781  0.7506623  -0.00191313]]
6 [[ 0.6663918   0.57066243  0.95327862  0.80509071 -0.01083923]]
7 [[ 0.6963287   0.60773819  0.96685725  0.79948358 -0.01999958]]
8 [[ 0.71034289  0.62288578  0.97451682  0.80648027 -0.0283544 ]]
9 [[ 0.71196346  0.67153992  0.7643083   0.62996063 -0.0225186 ]]

이를 그래프로 표현하면 아래와 같다.

마지막 \(h_9\)의 값은 Keras 계산 결과와 동일하며, 따라서 우리가 위키피디어 수식을 기준으로 구현한 NumPy 코드는 Keras 구현과 동일함을 확인할 수 있다.

코드

전체 코드는 lstm-keras-inspect.py에서 확인할 수 있다.

References

is a collection of Papers I have written.
© 2000 - Sang-Kil Park Except where otherwise noted, content on this site is licensed under a CC BY 4.0.
This site design was brought from Distill.