본문 바로가기

Deep Learning/Natural Language Processing

Character Based Sequence to Sequence Learning

 Character Based Sequence to Sequence Learning은 말그대로 문자에 기반한 Seq2Seq 학습이다. 개별 문자를 통해 문자 사전을 구축하고 해당 사전을 토대로 입력 Sequence에 대해 학습해 Sequence형태의 출력을 실행한다. 추후 Seq2Seq 모델에 대해 기계번역에서의 분석으로 자세히 기술할 것이므로 설명은 생략하고자한다.

 

 여기서 실행하는 분석은 총 2가지이며 2가지 모두 개별 문자에 기반한 NLP 학습이다. 데이터의 직접적인 생성부터 학습을 통한 모델 구축까지의 과정을 내포한다. 인터넷이 연결되어있지 않은 폐쇄망에서 text에 관련해 어떤 것을 해보면 좋을지 생각하다가 Keras 문서에 있는 코드들을 참조하게 되었으며, 언급했듯이 데이터 수급이 어려운 환경에서도 데이터를 직접 생성해 Sequence모델을 실행해볼 수 있는 좋은 예시로써 실행해보았다. 


< Additive Learning Model >

1. Data Generation

 

 첫번째 코드는 비록 Keras 문서에 있는 Code Example 중 keras.io/examples/nlp/addition_rnn/ 를 참조하였으며 일부 함수의 경우 편의에 맞게 변형하였다. 해당 모델은 숫자 연산 중 세자릿 수의 덧셈을 학습하는 모델로써 Input으로 문자형태를 입력받는데 이는 곧 연산에 대한 수식이된다. 

import tensorflow as tf
from keras.layers import Dense, Bidirectional, LSTM, RepeatVector
from keras.models import Sequential
import numpy as np
import matplotlib.pyplot as plt

# Parameters for the model and dataset.
training_size = 50000
digits = 3
reverse = False

# Maximum length of 'int + int'
maxlen = digits + 1 + digits

 digits는 3으로 입력될 문자열의 길이가 3(세자릿 수)이므로 3으로 설정되며 maxlen은 입력될 Input의 길이를 의미한다. 예를들면 '365+411' 의 형태로 문자열이 입력되므로 Input size는 항상 7로 고정된다.

 

# Making word based dictionary & Encoding words to one-hot vector
class CharacterTable:
  def __init__(self, chars):
    self.chars = sorted(set(chars))
    self.char_indices = dict((c, i) for i, c in enumerate(self.chars))
    self.indices_char = dict((i, c) for i, c in enumerate(self.chars))

  def encode(self, C, num_rows):
    x = np.zeros((num_rows, len(self.chars)))
    for i, c in enumerate(C):
      x[i, self.char_indices[c]] = 1
    return x

  def decode(self, x, calc_argmax=True):
    if calc_argmax:
      x = x.argmax(axis=-1)
    return "".join(self.indices_char[x] for x in x)
# 산출되는 숫자가 3자리 또는 4자리로 ' '가 포함되는 경우가 있어 ' '의 문자열도 고려함
chars = "0123456789+ "
ctable = CharacterTable(chars)

 CharacterTable이라는 Class는 개별 문자들에 정수 인코딩을 수행해 각 문자들이 매칭된 개별 숫자를 가지도록 한다. 해당 숫자를 통해 문자열을 One-hot vector로 encoding되는 encode라는 함수를 가진다. 반대로 encoding된 vector를 다시 원본 문자열로 변환하는 decode함수를 정의함으로써 특정 one-hot vector가 어떤 원본 문자열로부터 파생되었는지 확인하는 것에 사용된다. 0부터 9까지의 숫자들이 사용되며 덧셈 연산기호인 '+' 와 공백 역시 문자열로 인식해 고정된 길이를 가지도록 한다.

 

questions = []
expected = []
seen = set()

print("Generating data...")

while len(questions) < training_size:
  # 'a+b' 에서 랜덤ㅇ로 a,b를 생성
  a, b = np.random.randint(1,1000), np.random.randint(1,1000)

  # a+b = b+a 이므로 중복된 a+b가 산출시 skip
  key = tuple(sorted([a, b]))
  if key in seen:
    continue
  else:
    seen.add(key)

  # maxlen에 따라 남는 길이가 있다면 뒤를 공백으로 추가해 padding을 실시
  q = "{}+{}".format(a, b)
  query = q + " " * (maxlen - len(q))
  
  # 출력될 수 있는 정답의 최대길이에서 남는 부분은 공백으로 추가
  ans = str(a + b)
  ans += " " * (digits + 1 - len(ans))

  if reverse:  
    query = query[::-1]

  questions.append(query)
  expected.append(ans)
  
print("Total questions:", len(questions))

 

2. Vectorization

 

 학습 및 평가에 사용할 데이터를 생성한다. 총 50,000여개의 연산질문과 그 답에 해당하는 데이터를 random sampling을 통해 생성한다. 해당 과정에서 중복되는 질문은 제거되며 질문과 답 모두 고정된 길이를 가진다. 길이를 맞추기위해 남는 부분은 공백형태의 문자열로 투입되어 일종의 padding과정을 수행한다.

# (데이터수, 질문 최대길이, 사용되는 문자(숫자))의 영행렬을 생성
def vectorization(questions, expected, chars, x_maxlen, y_maxlen):
  x = np.zeros((len(questions), x_maxlen, len(chars)))
  y = np.zeros((len(questions), y_maxlen, len(chars)))

  # 생성된 영행렬에 one-hot vector을 삽입
  for i, sentence in enumerate(questions):
    x[i] = ctable.encode(sentence, x_maxlen)
  for i, sentence in enumerate(expected):
    y[i] = ctable.encode(sentence, y_maxlen)

  return x, y

x, y = vectorization(questions, expected, chars, maxlen, digits+1)
# Split Train set, Validation set
def train_val_split(x,y):
  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:]

  print("Training Data:")
  print(x_train.shape)
  print(y_train.shape,'\n')

  print("Validation Data:")
  print(x_val.shape)
  print(y_val.shape)
  return x_train, x_val, y_train, y_val

x_train, x_val, y_train, y_val = train_val_split(x,y)

 

 구축된 데이터를 바탕으로 One-hot vector로 만들어주는 vectorization 함수이다. 생성된 zero vector에 encoding된 문자열의 index에 따라 1을 넣어 vectorization을 수행한다. 그리고 50,000개 중 45,000개를 Train셋, 5,000개를 Validation셋으로 분할하여 학습 및 평가 데이터에 대한 준비를 마친다. 준비된 데이터 셋을 보면 다음과 같은 형태를 가진다.  

아래와 같은 2차원 One-hot vector가 Input, Output으로 사용된다.

 

print('Question:', questions[53], '\n')
print('Answer:', expected[53], '\n')

print("chars location", sorted(set(chars)),'\n')

print('Encoded Question:\n\n', x_train[53], '\n')
print('Encoded Answer:\n\n', y_train[53])

 

3. Modeling

# Build LSTM Sequence Model
def model_basic(num_layers):
  model=Sequential()
  model.add(LSTM(128,input_shape=(maxlen, len(chars))))
  model.add(RepeatVector(digits+1)) # target값이 4의 행을 가지므로 변환
  for _ in range(num_layers):
    model.add(LSTM(128, return_sequences=True))
  model.add(Dense(len(chars),activation='softmax'))

  model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

  return model

 모델링 단계에서는 2번의 LSTM을 사용하였다. 첫번째 LSTM layer는 해당 모델에서 Encoder 역할을 수행하며 for 문에 있는 두번째 layer는 Decoder 역할을 수행한다. 중간에 사용된 RepeatVector는 target값의 shape의 형태로 변환한다. 최상위층에 Dense layer를 통해 softmax연산을 수행해 각 위치의 확률값을 산출한다.

 

model= model_basic(1)

epochs=35
batch_size=32

def fitting_visualize(x_train, y_train, x_val, y_val, model, epochs, batch_size):
  for epoch in range(1,epochs+1):
    print()
    print("Iteration",epoch)
    model.fit(x_train,y_train, batch_size=batch_size, epochs=1, validation_data=(x_val, y_val))

    for i in range(10):
      ind = np.random.randint(0, len(x_val)) # x_val 개수인 5000개 중 랜덤 index값 설정
      rowx, rowy = x_val[np.array([ind])], y_val[np.array([ind])] # 해당 index 값의 x_val, y_val을 설정

      # 모델로 예측확률의 최대값을 preds에 할당
      preds = np.argmax(model.predict(rowx), axis=-1) 

      q = ctable.decode(rowx[0]) # decode로 실제 질문을 추출
      correct = ctable.decode(rowy[0]) # decode로 실제 답을 추출
      guess = ctable.decode(preds[0], calc_argmax=False) # decode로 예측결과 답을 추출
    
      print("Q", q, end=" ")
      print("T", correct, end=" ")
      if correct == guess:
        print("v " + guess)
      else:
        print("x " + guess)

fitting_visualize(x_train, y_train, x_val, y_val, model, epochs, batch_size)

1st Epoch

 첫번째 epoch에서는 Train셋의 학습 정확도와 validation set의 학습 정확도가 모두 떨어지며 loss 역시 크게 나타난다. Decoding된 문제와 답을 보면 역시 제대로 맞추지 못하는 것이 나타난다.

 

10st Epoch

. . .

Final Epoch

 

 하지만 학습이 진행됨에 따라 loss가 줄어들고 오분류율이 낮아진다. 실제로 10번의 epoch만에 val_accuracy는 0.9390을 달성하며 실제로 10개의 문제중 9개를 맞히고 있어 학습이 성공적으로 이루어지고 있는 것을 확인할 수있다. 최종적으로 35번째 epoch에 정확도는 약 99%를 달성했으며 모든 질문에대해 올바른 output을 산출한다. 

 


 

< Date Transforming Learning >

 

 두번째로, 년도 변환 학습이다. 이 역시 개별 문자에 근거한 Sequence 모델로써 위의 분석과 완벽히 동일한 논리를 가진다. 다만 위에서 덧셈에 대한 연산을 학습시켰다면, 여기서는 정제되지 않은 형태의 날짜를 학습해 일정한 format을 가진 형태로 변환해주는 역할을 수행한다. 그러다보니 사용되는 문자가 더 많다. 실제로 Python은 다양한 함수들을 통해 비정제된 날짜데이터를 정형화된 format으로 쉽게 가공할 수 있다. 하지만 날짜데이터가 동일한 유형의 비정제된 형태가 아닌 다양한 유형으로 구성된 형태라면 case를 나누어 각각에 대해 직접적인 정제를 수행해주어야하지만 학습을 통한 딥러닝에서는 이러한 형태들을 종합해 쉽게 일관된 format을 반환할 수 있다.

 

1. Data Generation

 

# Parameters for the model and dataset.
training_size = 50000

# Maximum length of Answer
maxlen = 20

# 사용되는 '월' 문자열 생성
month=['january','february','march','april','may','june','july','august','september','october','november','december']

# 해당하는 '월'을 숫자로 매핑하는 dictionary
month_to_ind=dict((c,i+1) for i,c in enumerate(month))
# 문자형식의 날짜와 매칭되는 답 데이터들을 생성
# 총 3가지 유형으로의 날짜를 생성
def data_generation(size):
  questions=[]
  answers=[]

  for i in range(size):
    seed = np.random.randint(0,3)
    
    if seed==0:
      q = np.random.choice(month)+' '+str(np.random.randint(1,32))+'th, '+str(np.random.randint(1900,2022))
      a = q.split()[2]+'-'+str(month_to_ind[q.split()[0]])+'-'+q.split()[1][:-3]

    if seed==1:
      q = str(np.random.randint(1900,2022))+' '+np.random.choice(month)+' '+str(np.random.randint(1,32))+'th'
      a = q.split()[0]+'-'+str(month_to_ind[q.split()[1]])+'-'+q.split()[2][:-2]

    if seed==2:
      q = str(np.random.randint(1,32))+'th '+np.random.choice(month)+' '+str(np.random.randint(1900,2022))
      a = q.split()[2]+'-'+str(month_to_ind[q.split()[1]])+'-'+q.split()[0][:-2]
    
    q += ' '*(20-len(q))
    a += ' '*(10-len(a))

    questions.append(q)
    answers.append(a)

  return questions, answers 
questions, answers = data_generation(training_size)

print('Question Samples:\n',questions[:5],'\n')
print('Answer Samples:\n',answers[:5])

Sample data

 

 모든 경우의 수에 있어서 Question 문자열이 가지는 최대 길이는 20, Answer 문자열은 10의 길이를 갖는다. 연산학습모델에서와 마찬가지로 최대길이를 설정하고 random seed를 통해 총 3가지 유형으로 날짜를 의미하는 문자열을 생성해 data generation을 수행한다.

 

2. Vectorization

# 산출되는 숫자가 3자리 또는 4자리로 ' '이 포함되는 경우가 있어 ' '의 문자열도 고려
chars='0123456789-abcdefghijklmnopqrstuvwxyz, '
ctable=CharacterTable(chars)
x, y = vectorization(questions, answers, chars, maxlen, 10)

print("x shape", x.shape)
print("y shape", y.shape)

x_train, x_val, y_train, y_val = train_val_split(x,y)

 

 One-hot encoding을 통해 vectorization된 데이터는 1과 0으로 개별문자들의 위치에 따라 행렬을 생성한다.

 

3. Bidirectional LSTM Model

 

https://pozalabs.github.io/blstm/

 

 모델의 전반적인 구조는 이전에 사용한 구조와 거의 유사하나, 여기서는 Bidirectional LSTM을 사용하였다. Bidirectional LSTM은 기존 LSTM에서 이전에 들어온 정보를 예측에 활용한 것에 더해 이후에 얻는 정보들도 활용하는 sequence에 대한 양방향을 모두 이용한 방식이다.

 

 Time step 이 1부터 t 까지 있다고 가정할 때 Forward LSTM 에서는 Input을 time step 이 1 일때부터 t 까지 순차적으로 주고 학습한다. 반면 Backward LSTM 에서 Input을 t 일때부터 1까지 역으로 Input 주고 학습하게된다. 이를 통해 각 time step 마다 두 모델에서 나온 2개의 hidden vector는 학습된 가중치를 통해 하나의 hidden vector로 생성된다. 순방향 LSTM과 역방향 LSTM을 concat하여 사용하는 형태다. 

 

# Build Bidirectional LSTM Sequence Model
def bd_lstm_model(num_layers):
  model=Sequential()
  model.add(Bidirectional(LSTM(128),input_shape=(maxlen, len(chars))))
  model.add(RepeatVector(10)) # target값이 10의 행을 가지므로 변환
  for _ in range(num_layers):
    model.add(LSTM(128, return_sequences=True))
  model.add(Dense(len(chars),activation='softmax'))

  model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

  return model

model=bd_lstm_model(1)
epochs=5
batch_size=32

fitting_visualize(x_train, y_train, x_val, y_val, model, epochs, batch_size)

1st epoch

. . .

5th epoch

 

 총 5번의 epochs동안 학습을 수행했다. 학습이 진행됨에 따라 빠르게 validation loss는 감소하여 학습이 올바르게 진행되었다. 4epoch만에 Train셋과 Validation셋의 Accuracy가 모두 1로 수렴하였다. Bidirectional LSTM은 이와같이 입력되는 데이터의 순서가 뒤바뀌는 것에 크게 영향을 받지않는 데이터에 대해서 높은 수준의 성능을 보여준다. 실제로 연산학습에서 사용한 구조의 모델로 동일한 hyper-parameter로 학습을 진행했을때, val_acc와 train_acc가 1이 되는 시점은 5epoch를 초과하였으며 동일한 epoch에서 성능을 비교했을때도 적합이 더딘 모습을 쉽게 확인했다.

 

def model_inference(test):
  test += ' '*(20-len(test))
  t = np.zeros((1,maxlen,len(chars)))
  for i, sentence in enumerate(test):
    t[0][i]=ctable.encode(sentence,maxlen)[0]

  prediction = ctable.decode(np.argmax(model.predict(t.reshape(1,20,39)),axis=-1)[0],calc_argmax=False)
  
  return prediction
model_inference('april 5th 2012').strip()

 

 마지막으로 model_inference function을 이용하면 입력되는 새로운 데이터에 대해 동일한 전처리를 수행하고 학습된 모델을 통해 날짜변환을 완벽하게 수행한다. 이렇게 날짜 변환 딥러닝 모델은 종결될 수 있지만, 다양한 날짜 형식을 입력해보면서 약간 틀린 format이나 오타가 포함될때 어떻게 변환이 수행되는지 보고자 부정확한 형태의 문자열을 입력했다. 결과는 다음과 같다.

print('Inaccurate Test 1:',model_inference('2013 sepstemer 25th').strip())
print('Inaccurate Test 2:',model_inference('2019 julqy 29th').strip())
print('Inaccurate Test 3:',model_inference('15th octobe, 1944').strip())
print('Inaccurate Test 4:',model_inference('marh, 22th 1903').strip())

 

 학습 데이터 생성 과정에서, 총 3가지 유형으로 구분하여 생성하였다. 따라서 모델은 해당 3가지 유형에 대해서는 완벽하게 학습하고있다. 따라서 변화를 줄때 오탈자와 유형이탈, 이렇게 두가지로 구분하여 부정확한 문자열을 입력했다.

 

 1의 입력 format은 유형은 이탈하지 않았으나 오타를 포함하고있다. 9월을 나타내는 september에서 중간에 's'를 대입하고 'b'를 제거하였다. 2 역시 1과 동일하게 가벼운 오타를 포함한다.

 3은 오탈자와 유형이탈을 모두 포함한다. ','는 생성규칙에 따르면 날짜뒤에 나타나야하지만 여기서는 month 뒤에 위치해있으며 october의 철자중 'r'이 부재하고있다. 4 역시 3과 동일하게 유형이탈과 오탈자를 모두 포함한다.

 

 이렇게 다양한 경우에 대해 실험해본 결과 위에서 볼 수 있듯이 가벼운 수준의 오탈자, 유형이탈은 모델이 허용한다. 즉, 올바르게 날짜를 변환하여 산출한다. 하지만 형태의 변형이 심화되거나 과도할 경우 특히 두자릿수의 날짜 문자열에 대해 제대로 인식하지 못하는 것으로 드러났다. 또한 유형이탈보다는 오탈자에 대해 더 관대한 모습을 볼 수 있었다. 

 이는 학습과정에서 설령 문자열 중 일부가 빠져있다해도 주변 문자열들을 통해 어떤의미를 가지는지 내부적으로 모델이 추론하고있는 것이며 유형이탈의 경우 이러한 주변문자열이 오히려 혼란을 야기시키므로 제대로된 변환을 수행하지 못하는 것으로 보인다.