Sequential Data

시간 축으로 순서가 있는 데이터를 순차적(sequential) 데이터라고 합니다. 이런 데이터는 보통 재귀형 신경망(recurrent neural network, RNN)같은 방식으로 다루게 되는데, 바로 확인하면 되는 이미지와 달리 바로 이해하기 힘든 다차원 텐서 형태로 다뤄지는 것이 보통입니다. 데이터의 변화를 바로 파악하기 힘들고 실수 차원의 벡터 형태로 임베딩(embedding) 되는 것이 일반적이기 때문에 순차적 데이터를 다루는 데는 특별한 방법이 필요합니다.


Sequence Preprocessing

순차적 데이터의 경우 꼭 해 줘야 할 작업은 텐서 형태로 데이터를 맞추는 것입니다. 대부분의 파이썬 기반 RNN 패키지들은 3차원 텐서로 데이터를 만들고 있습니다.

(batch size, sequence length, dimension)

즉, 한 개의 미니 배치(mini-batch)에 들어 있는 데이터는 모두 시간 축에서의 스텝(time step) 수가 같아야 하고 차원이 동일해야 합니다. 하지만 모든 데이터가 같은 길이일 수는 없습니다. 문장의 경우 서로 다른 문장은 보통 서로 다른 단어 수로 구성되기 마련입니다. 그래서 이 불일치를 맞춰주는 작업이 필요합니다.

Tokenize

토큰(token)이란 보통 텍스트 데이터에서 사용하는 최소 단위를 의미합니다. 영어의 경우 보통 단어가 되겠지요. 토큰화(tokenize)는 텍스트 형태의 데이터를 잘 쪼개 토큰으로 만드는 과정입니다. 텍스트 데이터의 전처리 과정이라고 볼 수도 있을 것 같습니다.

"I’d like to have a -- lovely cat."

이 문장을 토큰화해 보겠습니다. 단순히 공백으로만 분리하면 (”I’d) (like) (to) (have) (a) (--) (lovely) (cat.”) 이 됩니다. 우리가 원하는 형태는 이게 아니기 때문에, 가장 이상적인 경우는 알아서 (i) (would) (like) (to) (have) (a) (lovely) (cat) 으로 바꿔주는 것입니다. 파이썬의 split 함수를 사용하면 쉽게 할 수 있습니다.

각각의 단어들을 사전 형태로 만들면 빈도수를 기록할 수 있습니다. 보통 3회 이하 등, 너무 적은 횟수만큼 등장한 단어의 경우 (#UNK) 과 같이 하나의 집합으로 모아버리곤 합니다. 이렇게 UNK로 모인 단어들은 훈련 과정에서 전부 UNK라는 답을 달게 되는 것입니다. 이 외에도 숫자는 모두 (N) 으로 바꿔 버리기도 합니다.

문장의 시작에 (sos), 문장의 끝에 (eos) 를 넣기도 합니다. 각각 Start-of-sentence, End-of-sentence 의 약자입니다. 보통 sos는 안 넣지만 eos는 많이 넣습니다. 문장이 끝났다는 것 까지 학습하도록 하기 위해서는 eos를 넣어 주는 것이 좋습니다. 위의 UNK, EOS, SOS 등은 특수한 토큰입니다.

영어에서는 상대적으로 토큰화가 쉽다고 느껴질 수 있지만, 표음문자가 아니거나 표음문자들을 사용하더라도 평이한 순서로 나오지 않는 경우 토큰화는 아주 골치아파집니다. 중국어의 경우는 어떻게 토큰화를 해야 할까요? 한국어의 경우는 어디서부터 어디까지를 토큰으로 삼아야 할까요? 이에 대한 연구들은 아무래도 인공지능쪽에서 이루어지지는 않고 있는 것 같습니다. 주로 음성학, 언어학을 연구하는 사람들의 몫이라고 할 수 있습니다. 실제로 대부분의 데이터는 영어로 만들어져 있지만, 한국어 응용을 만들고 싶은 경우는 반드시 고민해야 할 부분이기도 합니다.

Padding

앞서 말한 대로 스텝 수를 맟춰 주는 과정이 패딩(padding)입니다. 이미지에 있는 zero-padding 처럼, 모자란 스텝에 해당하는 만큼을 무의미한 값으로 채우는 것입니다. 예를 들어, 길이가 4, 5, 7 인 세 개의 순차 데이터를 합치는 경우 전체를 7로 채우고 앞의 데이터들에 더미(dummy) 스텝을 각각 3개와 2개 넣는 것입니다.

마스크(mask)란, 어디까지가 진짜 데이터고 어디가 더미 데이터인지를 표시한 값입니다. 만약 언어 모델(language model)을 만드는 과정이라면 데이터를 다루는 모듈은 훈련 데이터, 훈련 정답과 함께 마스크도 만들어야 합니다. 마스크는 후에 정확도를 계산하는 등 다양한 용도로 사용해야 하니 반드시 해당하는 미니 배치에 맞는 마스크를 사용할 수 있도록 해야 합니다.

보통 패딩은 0으로 하는데, 이 경우 잠재적인 문제를 내포하고 있습니다. 토큰의 인덱스가 0으로 시작하는 경우, 더미임을 나타내는 0과 0번 토큰임을 나타내는 0이 구분되지 않기 때문입니다. 물론 이렇게 섞어 쓰더라도 마스크를 잘 만들면 문제가 없긴 합니다. 이 문제를 zero-indexing 문제라고 합니다.

선택지는 세 가지입니다. 더미임을 나타내는 특별한 기호를 만들기, 더미에 0을 주고 실제 토큰은 1번부터 시작하기, 그냥 혼용해서 사용하고 마스크를 잘 씌우기 입니다. 셋 다 문제가 있으니 잘 사용하기 바랍니다.

첫째, 더미를 나타내는 특별한 기호 만들기는 쉽지 않습니다. 보통 단어를 표현할 때 인덱스로 표현하고 이 인덱스를 임베딩 행렬(embedding matrix)에서 찾아 다차원 실수 벡터로 바꾸는데, 특별한 기호를 사용하는 경우 임베딩 행렬에서 찾기 어려울 수 있습니다. UNK, EOS, SOS에 이은 DMY 를 만든다면 이 방법이 가능할 수 있습니다.

둘째, 더미에 0을 주고 실제 토큰은 1번부터 사용하는 방법은 위의 임베딩 행렬 문제를 피해갈 수 있습니다. 그리고 마스크도 따로 만들 필요가 없는데, tf.sign 함수로 양수를 찾으면 그게 실제 단어고 0을 찾으면 더미가 됩니다. 다만 이 경우 문제는 TF가 제공하는 cross-entropy 및 softmax 함수들을 사용하기가 어렵습니다. TF의 cross-entropy 함수들은 단어가 0번부터 있는 것을 가정하고 만들어졌기 때문에 그대로 사용하면 말도 안되는 값을 내보내게 되니 별도의 작업이 필요합니다.

셋째, 섞어 쓰고 마스크를 잘 만들기는 사실 잘만 되면 아무런 문제가 없습니다. 손실값, 각 스텝에서의 출력과 스테이트(state) 같은 것만 잘 신경써 줄 수 있으면 사실 제일 권장하는 방법이기는 합니다.

Bucketing

다른 많은 데이터와 마찬가지로 순차 데이터도 가능하다면 매 epoch 마다 순서를 섞어 미니 배치를 만드는 것이 좋습니다. 그런데 데이터의 문장마다 길이는 천차만별이고, 최악의 경우 엄청 긴 하나와 엄청 짧은 나머지들이 합쳐져 미니 배치가 만들어지면 가장 긴 하나에 맞게 패딩이 이루어질 테니 엄청난 비효율이 발생합니다. 직접 C++로 짜면 모를까, 3차원 텐서를 입력으로 받는 TF의 경우 패딩을 안 하기도 힘듭니다. 이런 비효율을 그래도 최대한 방지하고자 하는 노력이 버켓(bucket)에 넣는 방법입니다.

개념은 간단한데, 우선 데이터들을 스텝 길이에 따라 정렬한 뒤 몇 개의 그룹으로 나누고, 데이터를 섞더라도 그 그룹 안에서만 섞도록 하는 것입니다. 20 단어 이하, 21개 이상 40개 이하, 41개 이상 등으로 그룹을 분리할 수 있을 것입니다. 이 때는 길이가 제한되어 있으니 어느 정도 비효율적인 더미 배치가 되지는 않을 것입니다. 만약 외부에서 sequence length 를 지정해 주는 형태로 프로그래밍 했다면 각 버켓의 길이를 넣어 주면 되니 매번 동적으로 바꾸지 않아도 된다는 장점도 있습니다.


Sequence Masking

앞서 패딩에서 잠깐 마스크 이야기를 했는데, 이렇게 마스크를 씌우는 동작을 마스킹(masking)이라고 합니다. 마스크는 보통 0과 1로만 이루어진 같은 크기의 텐서로, 그냥 곱하기만 하면 불필요한 부분의 값은 전부 0이 되는 방식입니다.

Masking loss

Many-to-Many 타입의 RNN인 경우 제일 마지막에 나오는 출력도 3차원 텐서로 나오게 되는데, 이 때 정답과의 차이를 구하는 cross-entropy 함수를 사용했다면 그 손실값들 중 일부는 더미에서 구한 손실값이 됩니다. 손실값에 바로 tf.reduce_mean 을 사용하면 올바른 손실값이 아니게 됩니다.

  1. 마스크를 곱해서 더미에 해당하는 값들을 전부 0으로 만들고
  2. tf.reduce_sum 으로 값들을 전부 합하고
  3. 합한 값들을 마스크 전체의 합(0/1로만 되어 있으니 더미가 아닌 갯수)으로 나누기

바로 tf.reduce_sum 을 해도 사실 그 손실값을 줄이는 방향으로 훈련이 될 테니 훈련 자체는 문제가 없지만, 나중에 논문에 보고할 때 그대로 사용하면 과하게 틀린 것으로 나오게 될 테니 주의하세요!

Dynamic masking

마스킹이 되어 있다는 것은 미니배치의 각 스텝에서 어떤 것은 더미를 처리하고 어떤 것은 진짜를 처리한다는 것을 의미합니다. 우선, 이 경우의 문제는 RNN 내부에서 더미가 입력으로 들어왔을 때 어떻게 할 것인지를 생각해 봐야 합니다. 0번 단어가 들어왔을 때는 당연히 스테이트(state)를 사용해 출력(output)을 만들어야 겠지만, 더미 0이 들어왔을 때는 어떻게 해야 할까요?

가장 일반적인 해법은 더미인 경우 계산을 하지 않고 이전 스텝의 스테이트와 출력을 다시 한번 그대로 내보내는 것입니다. 이래야 패딩된 데이터의 제일 마지막 출력만 가져오더라도 각 데이터의 길이만큼만 동작한 것이 되기 때문입니다. 즉, 길이 4인 데이터가 7로 패딩된 경우 출력은 ABCDDDD 가 되어야 합니다. 이렇게 하지 않으면 ABCDEFG 가 되고 이 중에 4번째 것을 가져오라는 동작을 따로 짜야 합니다. Many-to-one, Many-to-many, Sequence-to-sequence 등 다양한 경우에서 꼭 신경써줘야 하는 부분입니다.

TF의 tf.nn.dynamic_rnn 에는 sequence length 를 넣어 주는 항이 있습니다. 이 항에 미니 배치의 실제 데이터 길이를 넣어 주면 그 길이만큼만 처리하고 나머지는 기존의 출력과 스테이트를 복사합니다. 그럼 당연히 전체 처리 시간이 줄어들어 더 효율적이라고 생각할 수 있는데, 조건문을 수행하는 과정이 그래프 구조에서는 그렇게까지 효율적이지는 않아서 나중에 마스킹 해주는 게 더 나을 수도 있습니다. 그래도 이렇게 sequence length 를 인자로 넣어 주는 것이 내부에 신경쓰지 않는 좋은 방법 중 하나입니다.

그렇다면 양방향(bidirectional) RNN 에서는 내부를 어떻게 동작시켜야 할까요? 마스킹을 뒤집어야 할까요? 아닙니다. 정방향 수행때는 마스크가 111...000 이었다면 역방향 수행 때도 111...000을 그대로 사용하면 됩니다. 단, 이 경우 더미를 먼저 보게 되는 것입니다. 더미를 계산해 실제 데이터를 다룰 때 쓰레기 값이 들어오면 안 되기 때문에, 양방향 RNN을 다루는 경우 더미에서 이전 출력과 스테이트를 복사하도록 하는 것이 필수입니다!