Intro

안녕하세요, nlp 의 주 분야여서 그런가 기계번역은 평소 항상 공부하고 싶던 영역 가운데 하나였습니다. 전공으로 할 정도의 심도 있는 이해는 물론 할 수 없겠지만 어느 정도 유명한 논문 위주의 트렌드와 코드 이해는 따라잡고 싶습니다. + 그리고 저는 현재 nmt 대회 참가를 준비하고 있기 때문이기도 합니다 👩‍💻

  • LSTM 개념, LSTM nmt 모델 코드

  • biLSTM + attention

  • transformer 개념 + open nmt 튜토리얼

  • nmt에 있어서 low resources 해결 문제

    • back translation

    • 굵직굵직한 논문 찾아서 공부하기

    • => 어떻게 이번 대회에서 학습이 잘 되기 위한 데이터를 구축할까 ? 인사이트 얻기 ! (제가 맡은 부분입니다. 잘 할 수 있겠죠 ? 🥺)

lstm 개념

그럼 지금부터 열심히 공부해보겠습니닷 🤜 그간 저는 lstm 을 이해하고자 갖은 노력을 하였습니다. 저에게 제일 좋았던 방법은 변수 명 정의를 정확하게 정리해보고 그냥 잘 정리된 그림 한 방에 이해하는 것이었습니다.

핵심 : rnn 의 은닉충인 단순 h 말고 C 도 이용하자

  • C : 기억/망각 정도를 학습한 쉘
  • gate : 신호의 양을 조정해주는 기법
    • forget gate : 그 전 신호에서 불필요한 정보 망각 “영어 문장에서 Snoopy is happy. You are happy “ 라는 문장이 있다고 하였을 때 “앞에 주어는 3인칭이니까 동사가 is 야” 를 기억하는 것은 불필요합니다. 주어가 바뀌었기 때문입니다. 이처럼 각 신호에서 더 이상 쓸모 없는 부분이 된 것은 잊어줘야 합니다.
    • input gate (=update gate) : 바뀐 정보를 새로 업데이트 해주기 “주어가 바뀌었어. 이제 더 이상 주어가 3인칭이 아니야”
    • output gate : 얼마나 밖에 표출할지 결정.

그리고 각 과정이 잘 정리된 그림입니다.

IMG_D138D8F704F5-1

물결 표시는 예비 C 라고 보시면 됩니다. (아직 C는 아닌 예비 C)

C가 아닌 예비 C인 이유는 t 시점에 총 들어온 신호(예비 C)에서 input gate 를 거쳐서 새로 업데이트해줘야하는 부분만 남겨야하기 때문입니다. (C)

예비 C(t) : tanh(W x h(t-1) + W(t) x X(t) + b(t) )

i(t) (input gate 를 거쳐서 나온 값) : sigmoid(W(i) x (h(t-1) + X(t) ) + b(i) )

f(t) (forget gate 를 거쳐서 나온 값) : sigmoid(W(f) x (h(t-1) + X(t)) + b(f))

C(t) : f(t) x C(t-1) + i(t) x 예비 C(t)

O(t) : sigmoid(W(o) x (h(t-1) + X(t) + b(o))

h(t),y(t) : O(t) x tanh(C(t))

각 시점마다의 뺄 꺼 다 빼고,최종 결정되어서 나온 output 을 그때그때 다 저장해놓기 위해서 hidden shell 에는 t 시점의 최종 결과와 동일한 벡터가 저장됩니다.

C (x) ; x 시점에서의 C 값을 의미합니다.

lstm nmt 모델 코드 (Word-Level 번역기 만들기)

위키독스 해당 링크의 코드를 제가 복습한 부분입니다. 저는 텐서플로우의 매우 유연하지 않은 순차형 API만 조금 만지작거린 정도이기 때문에 차근차근 이해가 필요했고, 그 과정을 공유해볼까 합니다.

우선 해당 링크의 코드에 나와있는 데이터 전처리 단계는 생략하겠습니다. 잘 전처리하면 다음과 같은 결과가 나옵니다.

sents_en_in, sents_fra_in, sents_fra_out = load_preprocessed_data()
print(sents_en_in[:5])
print(sents_fra_in[:5])
print(sents_fra_out[:5])
[['go', '.'], ['hi', '.'], ['hi', '.'], ['run', '!'], ['run', '!']]
[['<sos>', 'va', '!'], ['<sos>', 'salut', '!'], ['<sos>', 'salut', '.'], ['<sos>', 'cours', '!'], ['<sos>', 'courez', '!']]
[['va', '!', '<eos>'], ['salut', '!', '<eos>'], ['salut', '.', '<eos>'], ['cours', '!', '<eos>'], ['courez', '!', '<eos>']]

영어 -> 프랑스어 nmt 이기 때문에 sents_en_in 은 각 문장을 단어 토큰으로 쪼개서 array 에 넣어준 것의 나열이고 sents_fra_in / out 의 경우 lstm 디코더 모델을 활용해서 앞 토큰들을 보고 해당 토큰을 맞추는 방식으로 학습할 것이기 때문에 위와 같이 전처리되었습니다.

-> 토크나이징 -> 패딩을 해서 성공적으로 인코딩해줍니다. (기존 nlp와 동일)

그리고 해당 인코딩 값이 어떤 자연어 토큰인지, 해당 자연어 토큰이 어떠한 토크나이저 인코딩 값으로 변환되었는지를 각각 저장해줍니다. 결국 nmt 모델은 영어 문장을 인코더에 넣으면 디코더 아웃풋으로 프랑스어에 해당하는 숫자를 반환할 것인데 이 숫자를 다시 자연어 토큰으로 변환해야지만 비로소 우리가 알아볼 수 있는 영어 -> 프랑스어 번역이 완성되기 때문입니다.

src_to_index = tokenizer_en.word_index
index_to_src = tokenizer_en.index_word # 훈련 후 결과 비교할 때 사용

tar_to_index = tokenizer_fra.word_index # 훈련 후 예측 과정에서 사용
index_to_tar = tokenizer_fra.index_word # 훈련 후 결과 비교할 때 사용

주의할 점

제가 코드를 리뷰하면서 무척이나 햇갈렸던 부분들입니다.

  • return_sequences=True 로 설정한 경우, 각 t 시점의 output 을 출력합니다. many to many 등등에서 쓰는 설정입니다.

  • return_states=True 로 설정한 경우, 각 t 시점의 hidden state 까지 같이 출력됩니다. hidden state 는 밖에 표출되는 y(t) 이외에 블랙박스에 숨겨진 상태이므로 lstm 모델에서는 C(t),h(t) 가 될 것입니다.

    IMG_C650C618461A-1

  • units != timesteps != hidden cells 입니다.

    각 timestep 마다 hidden cells 가 있고 hidden cells 는 hidden units 로 구성되어있습니다.

    IMG_049493464B6D-1

    즉 hidden cell 은 각 단어를 몇 차원으로 출력할지 dimension 을 의미하게 됩니다.

    각 케라스 레이어에 hidden cell 을 LSTM (30) 이런 식으로 지정하므로 이 때 각 단어는 30차원 output 값으로 출력될 것입니다.

    인코더,디코더 설계

    latent_dim = 50 #한 단어당 차원 수 
    # 인코더
    encoder_inputs = Input(shape=(None,))
    enc_emb =  Embedding(src_vocab_size, latent_dim)(encoder_inputs) # 임베딩 층
    enc_masking = Masking(mask_value=0.0)(enc_emb) # 패딩 0은 연산에서 제외
    encoder_lstm = LSTM(latent_dim, return_state=True) # 상태값 리턴을 위해 return_state는 True
    encoder_outputs, state_h, state_c = encoder_lstm(enc_masking) # 은닉 상태와 셀 상태를 리턴
    encoder_states = [state_h, state_c] # 인코더의 은닉 상태와 셀 상태를 저장
    이제 디코더를 설계합니다.
    

encoder_outputs, state_h, state_c = encoder_lstm(enc_masking) # 은닉 상태와 셀 상태를 리턴

인 이유는 return_state=True 이기 때문입니다. 즉 각 시점마다 이후 시점으로 전달되는 (이런 정보가 t-1시점까지에 있었어) H,C 값도 같이 반환되게 됩니다.

저는 H(t),C(t) hidden state를 드라마 이전 화 summary 정도로 직관적으로 이해했습니다. 제가 너무 바쁠 때는 드라마 이전 화들은 건너뛰고 볼 때가 많은데 이 때 이전 화에서 무슨 무슨 일이 있었는지 빨리감기로 요약해서 보여주는 과정이 아닐까 싶습니다.

다음은 디코더입니다.

# 디코더
decoder_inputs = Input(shape=(None,))
dec_emb_layer = Embedding(tar_vocab_size, latent_dim) # 임베딩 층
dec_emb = dec_emb_layer(decoder_inputs) # 패딩 0은 연산에서 제외
dec_masking = Masking(mask_value=0.0)(dec_emb)

# 상태값 리턴을 위해 return_state는 True, 모든 시점에 대해서 단어를 예측하기 위해 return_sequences는 True
decoder_lstm = LSTM(latent_dim, return_sequences=True, return_state=True) 

# 인코더의 은닉 상태를 초기 은닉 상태(initial_state)로 사용
decoder_outputs, _, _ = decoder_lstm(dec_masking,
                                     initial_state=encoder_states)

# 모든 시점의 결과에 대해서 소프트맥스 함수를 사용한 출력층을 통해 단어 예측
decoder_dense = Dense(tar_vocab_size, activation='softmax')
decoder_outputs = decoder_dense(decoder_outputs)

IMG_518F9C1948B0-1

디코더 코드를 보시면 decoder_outputs, _, _ = decoder_lstm(dec_masking, initial_state=encoder_states) 입니다.

즉 인코더 모델이 돌면서 각 시점마다 hidden state (h,c)를 저장하게 되고 전달되고 전달되다가 디코더에까지 넘어갑니다. 인코더 문장의 전체 문맥을 담은 벡터라고 하여 context vector 이라고 부르기도 합니다.

짜놓은 모델에 학습 데이터,정답값 넣어주기

model = Model([encoder_inputs, decoder_inputs], decoder_outputs)

저는 처음에 sequential api 로 생각했기 때문에 인풋값을 두 개를 넣는 것이 너무 이해가 가지 않았습니다. 알고보니 functional api 는 순차형과는 달리 매우 유연하게 여러 개의 nput 을 받아서 학습할 수 있는 구조라는 것을 알게되었습니다. ✔️

IMG_31256FE5BBAF-1

그리고 코드의 하이퍼파라미터 설정해서 학습하는 과정은 기존 nlp task 와 동일하게 진행되어서 생략하였습니다.

test 과정

그러면 이제 아무 영어 문장이나 넣어서 나오는 프랑스어 디코더 아웃풋을 통해서 모델 성능을 가늠해볼 수 있습니다.

테스트용 인코더,디코더 모델

# 인코더
encoder_model = Model(encoder_inputs, encoder_states)

# 디코더
# 이전 시점의 상태를 보관할 텐서
decoder_state_input_h = Input(shape=(latent_dim,))
decoder_state_input_c = Input(shape=(latent_dim,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]

# 훈련 때 사용했던 임베딩 층을 재사용
dec_emb2= dec_emb_layer(decoder_inputs)

# 다음 단어 예측을 위해 이전 시점의 상태를 현 시점의 초기 상태로 사용
decoder_outputs2, state_h2, state_c2 = decoder_lstm(dec_emb2, initial_state=decoder_states_inputs)
decoder_states2 = [state_h2, state_c2]

# 모든 시점에 대해서 단어 예측
decoder_outputs2 = decoder_dense(decoder_outputs2)

decoder_model = Model(
    [decoder_inputs] + decoder_states_inputs,
    [decoder_outputs2] + decoder_states2)

decode_sequence 함수

def decode_sequence(input_seq):
    # 입력으로부터 인코더의 상태를 얻음
    states_value = encoder_model.predict(input_seq)

    # <SOS>에 해당하는 정수 생성
    target_seq = np.zeros((1,1))
    target_seq[0, 0] = tar_to_index['<sos>']

    stop_condition = False
    decoded_sentence = ''

    # stop_condition이 True가 될 때까지 루프 반복
    # 구현의 간소화를 위해서 이 함수는 배치 크기를 1로 가정합니다.
    while not stop_condition:
        # 이점 시점의 상태 states_value를 현 시점의 초기 상태로 사용
        output_tokens, h, c = decoder_model.predict([target_seq] + states_value)

        # 예측 결과를 단어로 변환
        sampled_token_index = np.argmax(output_tokens[0, -1, :])
        sampled_char = index_to_tar[sampled_token_index]

         # 현재 시점의 예측 단어를 예측 문장에 추가
        decoded_sentence += ' '+sampled_char

        # <eos>에 도달하거나 정해진 길이를 넘으면 중단.
        if (sampled_char == '<eos>' or
           len(decoded_sentence) > 50):
            stop_condition = True

        # 현재 시점의 예측 결과를 다음 시점의 입력으로 사용하기 위해 저장
        target_seq = np.zeros((1,1))
        target_seq[0, 0] = sampled_token_index

        # 현재 시점의 상태를 다음 시점의 상태로 사용하기 위해 저장
        states_value = [h, c]

    return decoded_sentence

하나하나 뜯어서 보면

output_tokens, h, c = decoder_model.predict([target_seq] + states_value) 이고

앞서 설계한 모델은 input 값 두 개 (decoder_input,decoder_states_inputs) 가 들어간다고 정의되어 있습니다.

decoder_model = Model( [decoder_inputs] + decoder_states_inputs, [decoder_outputs2] + decoder_states2)

그런데 decoder_states_inputs 자리에 넣어준 states_value 의 경우 states_value = encoder_model.predict(input_seq) 입니다. 그리고 encoder_model 은 encoder_model = Model(encoder_inputs, encoder_states) 이기 때문에 encoder_states 인 쭉쭉 전달되는 h(t),h(t-1),… ,c(t),c(t-1),… 를 반환합니다. 즉 디코더 첫 단어(토큰)의 경우, encoder_model 을 통한 영어 context vector (encoder_states) 가 decoder 모델의 인풋으로 들어가면 [decoder_outputs2] + decoder_states2 를 output 으로 출력합니다.

‘I am happy’ -> ‘Bon jour joutemu’ (저는 프알못이어서 그냥 아무 소리나 해보았습니다) 이라고 하면 ‘Bon’ + ‘I am happy’ 의 정보를 압축해서 담고 있는 context vector 이 모델에 들어가서 ‘jour’ + ‘h(1),c(1)’ 이라는 결과를 반환합니다.

이후부터는

states_value = [h, c] 으로 저장되기 때문에 ‘jour’ + ‘h(1),c(1)’ 를 넣어서 ‘joutemu +’h(2),c(2)’ 를 반환하고 … 이런 식으로 문장의 끝인 토큰을 만나기 전까지 단어들을 (각 단어에 해당하는 인덱스 값)을 잘 뱉어낼 수 있게 됩니다 !

output_tokens, h, c = decoder_model.predict([target_seq] + states_value) 인데

​ # 현재 시점의 예측 결과를 다음 시점의 입력으로 사용하기 위해 저장

target_seq = np.zeros((1,1))

target_seq[0, 0] = sampled_token_index

으로 target_seq 값을 앞서서 t시점에서 반환된 결과값으로 계속 갱신하기 때문에 현재 시점의 예측 결과를 다음 시점의 입력으로 사용할 수 있게 됩니다.

오늘 LSTM 개념, LSTM nmt 모델 코드 , biLSTM + attention, transformer 개념 + open nmt 튜토리얼 이렇게 공부했는데 못다한 포스팅은 다음에 또 시간 날 때 적으면서 복습해봐야할 것 같습니다 :)