Seq2Seq 모델 디버깅

Keras로 seq2seq 모델을 구축하는 어려움에 대해 얘기한다. 실제로 Keras는, 아니 TensorFlow는 Graph와 Session이 나뉘어져 있기 때문에 새로운 모델을 구축해 테스트 하기 쉽지 않은 편이며 디버깅 또한 어렵다. 그러나 몇 가지 디버깅을 쉽게 할 수 있는 기법이 있는데, 여기서는 Keras에서 제공하는 seq2seq 기반의 덧셈 모델을 직접 디버깅 하면서 하나씩 살펴보도록 한다.

2018년 6월 20일 초안 작성

본문

먼저 여기서 구현하는 내용은 Learning to Execute1 논문 중에서도 아주 간단한 모델을 실험하기 위해 Addition덧셈 Task 구현으로 제한한다.

Keras에서 이미 잘 구현한 코드를 제공하며, 나중에 보겠지만 덧셈에 대한 정의 없이 단순히 입출력에 대한 학습만으로도 매우 만족스러운 결과를 얻을 수 있다.

전처리

덧셈을 구현할 것이기 때문에 데이터는 따로 수집할 필요 없이 모두 생성하여 만들 수 있다. 5만개의 데이터를 생성하며 이 중 10%인 5천개를 평가셋으로 활용한다. 아래 코드가 복잡해 보이긴 하지만 모두 데이터를 생성하는 과정일 뿐이며, 최종적으로 원-핫 벡터로 구성한다. 입력 데이터는 최대 3자릿수의 덧셈이기 때문에 중간에 덧셈 기호를 포함하여 7자리. 문자열은 더하기와 공백을 포함 총 12개의 features로, 이를 원-핫 벡터로 표현하여 (None, 7, 12) shape로 입력값을 구성한다.

# Parameters for the model and dataset.
TRAINING_SIZE = 50000
DIGITS = 3
REVERSE = True

# Maximum length of input is 'int + int' (e.g., '345+678'). Maximum length of int is DIGITS.
MAXLEN = DIGITS + 1 + DIGITS

# All the numbers, plus sign and space for padding.
chars = '0123456789+ '
ctable = CharacterTable(chars)

questions = []
expected = []
seen = set()
print('Generating data...')
while len(questions) < TRAINING_SIZE:
    f = lambda: int(''.join(np.random.choice(list('0123456789'))
                            for _ in range(np.random.randint(1, DIGITS + 1))))
    a, b = f(), f()
    # Skip any addition questions we've already seen
    # Also skip any such that x+Y == Y+x (hence the sorting).
    key = tuple(sorted((a, b)))
    if key in seen:
        continue
    seen.add(key)
    # Pad the data with spaces such that it is always MAXLEN.
    q = '{}+{}'.format(a, b)
    query = q + ' ' * (MAXLEN - len(q))
    ans = str(a + b)
    # Answers can be of maximum size DIGITS + 1.
    ans += ' ' * (DIGITS + 1 - len(ans))
    if REVERSE:
        # Reverse the query, e.g., '12+345  ' becomes '  543+21'. (Note the
        # space used for padding.)
        query = query[::-1]
    questions.append(query)
    expected.append(ans)
print('Total addition questions:', len(questions))

print('Vectorization...')
x = np.zeros((len(questions), MAXLEN, len(chars)), dtype=np.bool)
y = np.zeros((len(questions), DIGITS + 1, len(chars)), dtype=np.bool)

for i, sentence in enumerate(questions):
    x[i] = ctable.encode(sentence, MAXLEN)
for i, sentence in enumerate(expected):
    y[i] = ctable.encode(sentence, DIGITS + 1)

# Shuffle (x, y) in unison as the later parts of x will almost all be larger digits.
indices = np.arange(len(y))
np.random.shuffle(indices)
x = x[indices]
y = y[indices]

# Explicitly set apart 10% for validation data that we never train over.
split_at = len(x) - len(x) // 10
(x_train, x_val) = x[:split_at], x[split_at:]
(y_train, y_val) = y[:split_at], y[split_at:]

한 가지 재밌는 점은 입력값을 뒤집는reverse 부분인데, 이는 디코더에서 인코더의 관련 부분까지 경로를 단축하여 long term dependencies 문제를 완화한다. (Sutskever et al., 2014)2 실제로 학습 결과, 입력 값을 뒤집지 않은 경우에 비해 약간의 개선 효과가 있었다.

모델

인코더는 RNN 그 중에서도 LSTM을 사용해 아래와 같이 구현한다.

model.add(LSTM(64, input_shape=(MAXLEN, len(chars))))

MAXLEN=7, len(chars)=12이므로 앞서 언급한 바와 같이 입력값은 (None, 7, 12)형태의 원-핫 벡터이며, 이를 64개의 LSTM을 이용해 인코딩한다. 64개의 LSTM 결과를 8x8 히트맵으로 표현하면 아래와 같다.

입력은 3자리의 덧셈이므로 출력은 999+999=1998 최대 4자리의 숫자가 된다. 여기서는 출력을 항상 4자리로 고정하고, 값이 작을 경우 패딩을 채워넣는 형태로 학습한다. 따라서 결과는 항상 4자리이며, 디코더 RNN의 입력으로 LSTM 결과를 4번 반복한다.

model.add(RepeatVector(DIGITS + 1))

히트맵이 길어서 이상해 보이지만 y축에 따른 x의 값이 모두 동일하며 같은 값이 네 번 반복됨을 확인할 수 있다.

model.add(LSTM(32, return_sequences=True))

32개의 LSTM 결과에서 last hidden state 뿐만 아니라 각 단계별 hidden state를 return_sequences=True를 통해 시퀀스 단위로 출력하도록 한다. 이 부분이 seq2seq로 문제를 풀이하기 위한 핵심으로, 아래는 네 번 입력의 각 시퀀스 단위 출력값의 히트맵이다.

이제 문자열 길이 만큼인 12개의 Dense로 연결한다. 이 값이 각 문자열의 결과이며, 시퀀스 단위 출력을 입력으로 하는 TimeDistributed로 받는다. 최근 버전의 Keras는 TimeDistributed로 받지 않아도 Dense가 3D 출력을 자동으로 처리하도록 되어 있다.

model.add(TimeDistributed(Dense(len(chars))))

이제 이를 softmax로 구한 각각의 값, x축은 문자열, y축은 문자열의 위치가 된다.

model.add(Activation('softmax'))

다른 부분은 결과가 뚜렷한데, 3번째 위치가 조금 애매하다. x축 2번째 부터 0이 시작되므로 출력값은 1004가 될 확률이 가장 높지만 1014도 근소한 차이로 가능성이 있다.

i = 1020
output_final = model.predict(np.array([x_val[i]]))

print(ctable.decode(x_val[i]),
      ctable.decode(y_val[i]),
      ctable.decode(output_final[0]))
--
793+716 1014 1004 # 입력값이 뒤집어져 있음에 유의

확인 결과 정답은 1014였다. 이 경우 두 번째 확률이 정답이다. 예시를 위해 일부러 오답인 경우를 골라냈지만 실제로는 아래와 같이 대부분이 정답이다. 이 모델은 epoch=50으로 학습했을때 정확도가 99%가 넘는 매우 우수한 성능을 보인다.

이제 최종 모델을 정리하면 아래와 같다.

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm_1 (LSTM)                (None, 64)                19712     
_________________________________________________________________
repeat_vector_1 (RepeatVecto (None, 4, 64)             0         
_________________________________________________________________
lstm_2 (LSTM)                (None, 4, 32)             12416     
_________________________________________________________________
time_distributed_1 (TimeDist (None, 4, 12)             396       
_________________________________________________________________
activation_1 (Activation)    (None, 4, 12)             0         
=================================================================

Keras 디버깅

이처럼 각 중간층을 히트맵으로 표현하면 디버깅을 아주 편리하게 할 수 있는데, 아래와 같이 별도 모델을 만들어 가능하다.

from keras.models import Model
intermediate_layer_model = Model(inputs=model.input,
                                 outputs=model.layers[3].output)
output = intermediate_layer_model.predict(np.array([x_val[i]]))

import matplotlib.pyplot as plt
import seaborn as sns
plt.clf()
sns.heatmap(output[0])
plt.show()

히트맵은 seaborn으로 표현했다. seaborn에는 히트맵 기능이 아예 포함되어 있어 매우 편리하다. 이외에도 Keras의 각 가중치를 아래와 같이 출력할 수 있다. 각각의 레이어가 학습이 잘 되는지, 한 쪽으로 saturating 되진 않는지 확인하는 용도로 매우 유용하다.

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

# suppress scientific notation
np.set_printoptions(suppress=True)
for name, weight in zip(names, weights):
    print(name, weight.shape)
    print(weight)
--
...
lstm_2/recurrent_kernel:0 (32, 128)
[[-1.0408165  -0.2809384   0.13282494 ... -0.24039435  0.2504792
   0.72786254]
 ...
 [-0.14936163  0.45101446 -0.20581605 ... -1.3611728  -0.41399273
   0.8902553 ]]
lstm_2/bias:0 (128,)
[ 0.26532182  0.25231144  0.21429922  0.3212162   0.21154173  0.2208827
 ...
  0.23285456  0.18422586]
time_distributed_1/kernel:0 (32, 12)
[[-0.20192355 -1.1649421  -1.28834     1.9135056   0.3855201   0.1202523
 ...
  -5.3226976   0.45999715  5.7362094   5.882915    3.6583703   3.2614176 ]]
time_distributed_1/bias:0 (12,)
[-0.14995517 -0.51968133  0.11768986  0.6964862   0.11642078 -0.12675315
...

Loss Function

이 모델은 전형적인 multi-class classification 문제이며 loss function으로 categorical cross entropy를 사용한다. 그런데 multi-label이 아닌 multi-class 이기 때문에 binary cross entropy로 진행하면 훨씬 더 빨리 학습될 수 있을거라 생각했다. 실제로 시작하면서 부터 acc가 0.93을 넘어서면서 잘 학습됐다. 너무 빨리 학습되는게 의아해서 참고 자료를 살펴보던 중 그게 아니라는 SO의 글을 발견했다. 한마디로 빨리 학습되는 것 처럼 보이지만 metric이 잘못되었다는 지적.

binary cross entropy의 수식을 보면 negative를 1-true로 학습하는데, 나머지 모든 negative에 대한 이 아니다. binary classification이라면 positive 외에는 모두 negative로 볼 수 있지만 multi-class는 그렇지 않다. Keras에서는 binary에 대한 metric이 정의되어 있지 않으며 아래 코드와 같이 metric을 보정해야 한다. 실제로는 binary cross entropy는 학습 성능에 도움이 되지 않음을 확인할 수 있다.

from keras.metrics import categorical_accuracy
model.compile(loss='binary_crossentropy',
              optimizer='adam', metrics=[categorical_accuracy])

성능

batch size=512일때 테이블의 값 acc, val_acc는 아래와 같다.

epochs LSTM GRU SimpleRNN
10 (0.36, 0.3594) (0.4195, 0.4269) (0.4699, 0.4694)
50 (0.6373, 0.6376) (0.6774, 0.6745) (0.8134, 0.8080)
100 (0.9595, 0.953) (0.7516, 0.7424) (0.9139, 0.9071)
200 (0.999, 0.9951) (0.8251, 0.812) (0.9593, 0.9503)
  • GRU도 (0.984, 0.9719) 까지 높일 수 있었으나 500 epochs가 필요했다.
  • batch size를 더 높일 경우 제대로 학습이 되지 않는다. loss가 더 이상 줄지 않는다.
  • 시계열 모델이 아닌 Dense 만으로는 acc 0.5714 이상 학습이 되지 않았다.
  • LSTM 하나만 사용하여 return_sequences로 Many-to-Many로 진행 해봤을때, 입력값을 reversed로 했음에도 불구하고 마찬가지로 LSTM이 전체 시계열에 대한 압축 정보가 아니며 이전 입력은 이후 입력을 볼 수 없기 때문에 acc 0.626 이상 학습이 되지 않았다.
    model.add(LSTM(64, input_shape=(MAXLEN, len(chars)), return_sequences=True))
    

Many-to-Many

Keras 공식 블로그에서 소개된 Seq2Seq의 Many-to-Many 모델은 다소 복잡하다. input과 output이 모두 가변 길이 이고, sequences 뿐만 아니라 states를 함께 사용한다. (Cho et al., 2014)3

학습과 추론은 아래와 같이 입출력이 조금 다르다.

학습

인코더의 states. 즉, hidden state와 cell state를 디코더의 입력으로 학습한다. 디코더의 states는 사용하지 않는다.

추론

인코더의 states는 첫 글자에 대해서만 입력으로 하고, 이후에는 디코더의 states를 다음 글자에 대한 입력으로 하면서 EOF에 도달할때 까지 추론을 수행한다.

코드

전체 코드는 아래에서 확인할 수 있다.

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-NC 4.0.
This site design was brought from Distill.