본문 바로가기

책리뷰

(시작하세요! 텐서플로 2.0 프로그래밍) 7장. 순환 신경망(RNN)

순환 신경망의 구조

RNN은 일반적인 신경망과 유사하지만 되먹임 구조를 가지고 있다는 차이점이 있다.


RNN을 통해 할 수 있는 것들

  • 이미지를 입력했을 때 이미지에 대한 설명을 생성(Image Captioning)
  • 문장의 긍정/부정을 판단하는 감성 분석(Sentiment Classification)
  • 하나의 언어를 다른 언어로 번역하는 기계 번역(Machine Translation)



주요 레이어 정리

순환 신경망의 가장 기초적인 레이어는 SimpleRNN 레이어이다. 실제로는 SimpleRNN 보다 이것의 변종인 LSTM 레이어와 GRU 레이어가 주로 쓰인다.


SimpleRNN 레이어

x는 SimpleRNN에 들어가는 입력, h는 출력, U와 W는 입력과 출력에 곱해지는 가중치이다.


SimpleRNN 레이어 생성 코드

여기서 return_sequences는 모든 층에서 출력을 낼지 결정하는 인자이다. True로 설정하면 모든 층에서 출력을 낸다. 주로 여러개의 RNN 레이어를 쌓을 때 쓰인다.

tf.keras.layers.SimpleRNN(units=1, activation='tanh', return_sequences=True)

간단한 예제로 SimpleRNN을 구동시켜보겠다. 시퀀스를 구성하는 앞쪽 4개의 숫자가 주어졌을 때 그 다음에 올 숫자를 예측하는 간단한 시퀀스 예측 모델을 만든다. 예를 들어 [0.0 , 0.1, 0.2, 0.3]이라는 연속된 숫자가 주어졌을 때 [0.4]를 예측하는 네트워크를 만드는 것이 목표이다.


데이터 생성

import numpy as np

X = []
Y = []
for i in range(6):
  # [0,1,2,3], [1,2,3,4] 같은 정수 시퀀스를 만든다.
  lst = list(range(i,i+4))

  # 위에서 구한 시퀀스의 숫자들을 각각 10으로 나눈 다음 저장한다.
  X.append(list(map(lambda c: [c/10], lst)))

  # 정답에 해당하는 4, 5 등의 정수 역시 앞에서처럼 10으로 나눠서 저장한다.
  Y.append((i+4)/10)

X = np.array(X)
Y = np.array(Y)
for i in range(len(X)):
  print(X[i], Y[i])

#---------------------출력---------------------#
[[0. ]
 [0.1]
 [0.2]
 [0.3]] 0.4
[[0.1]
 [0.2]
 [0.3]
 [0.4]] 0.5
[[0.2]
 [0.3]
 [0.4]
 [0.5]] 0.6
[[0.3]
 [0.4]
 [0.5]
 [0.6]] 0.7
[[0.4]
 [0.5]
 [0.6]
 [0.7]] 0.8
[[0.5]
 [0.6]
 [0.7]
 [0.8]] 0.9

모델 생성

input_shape=[4,1]에서 4와 1은 각각 timesteps, input_dim을 나타낸다.

model = tf.keras.Sequential([
              tf.keras.layers.SimpleRNN(units=10, return_sequences=False, input_shape=[4,1]),
              tf.keras.layers.Dense(1)
])

model.compile(optimizer='adam', loss='mse')
model.summary()
#---------------------출력---------------------#
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
simple_rnn (SimpleRNN)       (None, 10)                120       
_________________________________________________________________
dense (Dense)                (None, 1)                 11        
=================================================================
Total params: 131
Trainable params: 131
Non-trainable params: 0
_________________________________________________________________

학습

출력이 0.4, 0.5, 0.6, 0.7, 0.8, 0.9가 나와야한다. 얼추 비슷하게 나오는것을 확인할 수 있다.

model.fit(X, Y, epochs=100, verbose=0)
print(model.predict(X))
#---------------------출력---------------------#
[[0.34086785]
 [0.48083133]
 [0.60667235]
 [0.71746755]
 [0.81466305]
 [0.90097076]]

평가

평가를 위해 [0.6, 0.7, 0.8, 0.9]와 [-0.1, 0.2, 0.3, 0.4]를 넣어주겠다. 원하는 결과값은 각각 1.0, 0.5이다. 조금은 아쉬운 결과이다.

print(model.predict(np.array([[[0.6], [0.7], [0.8], [0.9]]])))
print(model.predict(np.array([[[-0.1], [0.1], [0.2], [0.3]]])))
#---------------------출력---------------------#
[[0.97939587]]
[[0.34084633]]


LSTM 레이어

SimpleRNN의 단점은 입력 데이터가 길어질수록, 즉 데이터의 타임스텝이 커질수록 학습 능력이 떨어진다는 점이다. 이를 장기의존성(Long-Term Dependency) 문제라고 한다. 입력 데이터와 출력 사이의 길이가 멀어질수록 연관관계가 적어진다.

이를 해결하기 위한 구조로 LSTM이 제시되었다. LSTM의 특징은 출력 외의 LSTM 셀 사이에서만 공유되는 셀 상태(cell state)를 가지고 있다는 것이다.

이전의 SimpleRNN에서는 타임스텝의 방향으로 h만 전달되었지만 LSTM 셀에서는 셀 상태인 c가 평행선을 그리며 함께 전달된다. 이처럼 타임스텝을 가로지르며 셀 상태가 보존되기 때문에 장기의존성 문제를 해결할 수 있다는 것이 LSTM의 핵심 아이디어이다.


LSTM 수식

U와 W는 입력과 출력에 곱해지는 가중치이다. i,f,o는 각각 Input, Forget, Output 게이트를 통과한 출력이다. f는 이전의 c를 새로운 c에 얼마나 반영할지를 결정한다. i는 새로운 데이터를 새로운 c값에 얼마나 반영할지 결정한다. 즉 이전 타임스텝의 셀 상태와 새로운 데이터를 합하여 새로운 c를 결정한다. 출력인 h는 새로운 셀값과 o를 합하여 결정한다.

LSTM의 학습 능력을 확인하기 위해 곱셈 문제(Multiplication problem)을 풀어본다. 곱셈문제는 고려해야할 실수 범위가 100개이고, 그 중에서 마킹된 두 개의 숫자만 곱해야한다.


데이터 생성

X = []
Y = []
for i in range(3000):
  # 0~ 1 범위의 랜덤한 숫자 100개를 만든다.
  lst = np.random.rand(100)
  # 마킹할 숫자 2개의 인덱스를 뽑는다.
  idx = np.random.choice(100, 2, replace=False)
  # 마킹 인덱스가 저장된 원-핫 인코딩 벡터를 만든다.
  zeros = np.zeros(100)
  zeros[idx] = 1
  # 마킹 인덱스와 랜덤한 숫자를 합쳐서 X에 저장한다. 
  X.append(np.array(list(zip(zeros, lst))))
  # 마킹 1인 값만 서로 곱해서 Y에 저장한다.
  Y.append(np.prod(lst[idx]))

print(X[0], Y[0])
#---------------------출력---------------------#
[[0.         0.98710945]
 [0.         0.30498488]
 [0.         0.48494634]
 [0.         0.35304262]
...
(중간생략)
...
[0.         0.52451862]
 [1.         0.06334274]
 [1.         0.63669038]
 [0.         0.84561895]
...
(이후생략)
0.04032971241560418 # Y값

모델 생성(SimpleRNN)

여기서는 Simple RNN과 LSTM 두 모델을 모두 생성하여 성능을 비교해보겠다.

앞서 말했듯이 여러층의 RNN에서는 SimpleRNN에 return_sequences인자를 True로 한 것을 볼 수 있다.

model = tf.keras.Sequential([
            tf.keras.layers.SimpleRNN(units=30, return_sequences=True, input_shape=[100,2]),
            tf.keras.layers.SimpleRNN(units=30),
            tf.keras.layers.Dense(1)
])

model.compile(optimizer='adam', loss='mse')
model.summary()

#---------------------출력---------------------#
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
simple_rnn_1 (SimpleRNN)     (None, 100, 30)           990       
_________________________________________________________________
simple_rnn_2 (SimpleRNN)     (None, 30)                1830      
_________________________________________________________________
dense_1 (Dense)              (None, 1)                 31        
=================================================================
Total params: 2,851
Trainable params: 2,851
Non-trainable params: 0
_________________________________________________________________

모델학습(SimpleRNN)

X = np.array(X)
Y = np.array(Y)
#2560개의 데이터만 학습시킨다. 검증 데이터는 20%로 지정한다.
history = model.fit(X[:2560], Y[:2560], epochs=100, validation_split=0.2)

학습 그래프(SimpleRNN)

훈련데이터의 loss는 점차 감소하지만, 검증 데이터의 val_loss는 감소하지 않고, 오히려 증가하는 듯 하다. 오버피팅이다.

import matplotlib.pyplot as plt
plt.plot(history.history['loss'], 'b-', label='loss')
plt.plot(history.history['val_loss'], 'r--', label='val_loss')
plt.xlabel('Epoch')
plt.legend()
plt.show()


평가(SimpleRNN)

정확도가 14% 정도로 별로 좋지 않다.

prediction = model.predict(X[2560:])
fail = 0
for i in range(len(prediction)):
  # 오차가 0.04 이상이면 오답입니다.
  if abs(prediction[i][0] - Y[2560+i]) > 0.04:
    fail += 1
print('correctness:', (440-fail)/400 * 100, '%')
#---------------------출력---------------------#
correctness: 14.249999999999998 %


모델 생성(LSTM)

이제는 LSTM 모델로 학습시켜 보겠다.

model = tf.keras.Sequential([
            tf.keras.layers.LSTM(units=30, return_sequences=True, input_shape=[100,2]),
            tf.keras.layers.LSTM(units=30),
            tf.keras.layers.Dense(1)
])

model.compile(optimizer='adam', loss='mse')
model.summary()
#---------------------출력---------------------#
Model: "sequential_2"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm (LSTM)                  (None, 100, 30)           3960      
_________________________________________________________________
lstm_1 (LSTM)                (None, 30)                7320      
_________________________________________________________________
dense_2 (Dense)              (None, 1)                 31        
=================================================================
Total params: 11,311
Trainable params: 11,311
Non-trainable params: 0
_________________________________________________________________

모델 학습(LSTM)

X = np.array(X)
Y = np.array(Y)
#2560개의 데이터만 학습시킨다. 검증 데이터는 20%로 지정한다.
history = model.fit(X[:2560], Y[:2560], epochs=100, validation_split=0.2)

학습 그래프(LSTM)

훈련데이터의 loss는 점차 감소하고, 검증 데이터의 val_loss도 감소합니다.

import matplotlib.pyplot as plt
plt.plot(history.history['loss'], 'b-', label='loss')
plt.plot(history.history['val_loss'], 'r--', label='val_loss')
plt.xlabel('Epoch')
plt.legend()
plt.show()


평가(LSTM)

정확도가 92.5%로 훌륭합니다.

prediction = model.predict(X[2560:])
fail = 0
for i in range(len(prediction)):
  # 오차가 0.04 이상이면 오답입니다.
  if abs(prediction[i][0] - Y[2560+i]) > 0.04:
    fail += 1
print('correctness:', (440-fail)/400 * 100, '%')
#---------------------출력---------------------#
correctness: 92.5 %


GRU 레이어

GRU 레이어는 LSTM 레이어와 비슷한 역할을 하지만 구조가 더 간단하기 때문에 계산상의 이점이 있고, 어떤 문제에서는 LSTM 레이어보다 좋은 성능을 보이기도 한다.

LSTM과의 가장 큰 차이점은 셀 상태가 보이지 않는다는 것이다. GRU 레이어에는 셀 상태가 없는 대신 h가 비슷한 역할을 한다.


GRU 수식

r은 Reset 게이트, z는 Update 게이트를 통과한 출력이다. Reset 게이트를 통과한 출력 r은 이전타임스텝의 출력인 h에 곱해지기 때문에 이전 타임 스텝의 정보를 얼마나 남길지를 결정하는 정도라고 생각할 수 있다. 4번째 수식을 보면 Update 게이트의 출력은 LSTM의 Input과 Forget 게이트의 출력의 역할을 동시에 수행하는 형태이다.


모델생성

GRU 레이어를 사용한 네트워크의 파라미터 수는 LSTM 레이어를 사용한 네트워크보다 23% 정도 감소했다.

model = tf.keras.Sequential([
            tf.keras.layers.GRU(units=30, return_sequences=True, input_shape=[100,2]),
            tf.keras.layers.GRU(units=30),
            tf.keras.layers.Dense(1)
])

model.compile(optimizer='adam', loss='mse')
model.summary()
#---------------------출력---------------------#
Model: "sequential_3"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
gru (GRU)                    (None, 100, 30)           3060      
_________________________________________________________________
gru_1 (GRU)                  (None, 30)                5580      
_________________________________________________________________
dense_3 (Dense)              (None, 1)                 31        
=================================================================
Total params: 8,671
Trainable params: 8,671
Non-trainable params: 0
_________________________________________________________________

모델 학습

X = np.array(X)
Y = np.array(Y)
history = model.fit(X[:2560], Y[:2560], epochs=100, validation_split=0.2)

학습 그래프

loss가 거의 0에 가깝게 낮아졌다.


평가

정확도가 무려 98%가 나왔다. LSTM보다 더 높은 수치이다.

prediction = model.predict(X[2560:])
fail = 0
for i in range(len(prediction)):
  if abs(prediction[i][0] - Y[2560+i]) > 0.04:
    fail += 1
print('correctness:', (440 - fail)/ 440 * 100, '%')

#---------------------출력---------------------#
correctness: 98.4090909090909 %


임베딩 레이어

임베딩 레이어(Embedding Layer)는 자연어를 수치화된 정보로 바꾸기 위한 레이어다. 자연어의 정보를 영어는 문자, 한글은 문자를 넘어 자소 단위로도 쪼갤 수 있다. 더 큰 단위로는 띄어쓰기 단위인 단어가 있다. 또 몇 개의 문자를 묶어서 파악하려는 n-gram 기법이 있다.

임베딩 레이어보다 좀 더 쉬운 기법은 자연어를 구성하는 단위에 대해 정수 인덱스(index)를 저장하는 방법이다. 단어를 기반으로 정수 인덱스를 저장하는 예를 보자. "This is a big cat"이라는 문장에 대해 정수 인덱스를 저장하면 처음 나오는 단어부터 인덱스를 저장한다. this는 0, is는 1, a는 2, big은 3, cat은 4로 저장된다.

이후 원-핫 인코딩 배열로 변환하게 된다.

This is a big cat → [0,1,2,3,4] → [[1,0,0,0,0],[0,1,0,0,0],[0,0,1,0,0],[0,0,0,1,0],[0,0,0,0,1]]

인덱스를 사용하는 원-핫 인코딩 방식의 단점은 사용하는 메모리의 양에 비해 너무 적은 정보량을 표현하는 것이다. 또 인덱스에 저장된 단어의 수가 많아질수록 원-핫 인코딩 배열의 두 번째 차원의 크기도 그에 비례해서 늘어난다.

따라서 원-핫 인코딩 방식에 비해 임베딩 레이어는 한정된 길이의 벡터로 자연어의 구성 단위인 자소, 문자, 단어, n-gram 등을 표현할 수 있다. 임베딩 레이어를 학습시키는 방법에는 Word2Vec, GloVe, FastText, ELMo 등이 있다.



긍정, 부정 감성 분석

감성 분석(sentiment Analysis)은 입력된 자연어 안의 주관적 의견, 감정 등을 찾아내는 문제이다. 여기서는 네이버 영화 평점 데이터를 이용해 긍정/부정 감성 분석을 해본다.


데이터셋 불러오기

여기에는 훈련 데이터 15만개, 테스트 데이터 5만개가 있다. 리뷰중 10만개는 별점이 14로 부정적인 리뷰이고, 나머지 10만개는 910으로 긍정적인 리뷰이다.

path_to_train_file = tf.keras.utils.get_file('train.txt', 
        '<https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt>')
path_to_test_file = tf.keras.utils.get_file('test.txt', 
        '<https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt>')

데이터 로드 및 확인

데이터의 각 행은 탭 문자(\t)로 구분돼 있다. 처음의 id는 각 데이터의 고유한 id이고, document는 실제 리뷰 내용이다. label은 긍정/부정을 나타내는 값으로 0은 부정, 1은 긍정이다.

# 데이터를 메모리에 불러온다. 인코딩 형식으로 utf-8을 지정해야한다.
train_text = open(path_to_train_file, 'rb').read().decode(encoding='utf-8')
test_text = open(path_to_test_file, 'rb').read().decode(encoding='utf-8')

# 텍스트가 총 몇 자인지 확인한다.
print('Length of text: {} characters'.format(len(train_text)))
print('Length of text: {} characters'.format(len(test_text)))

# 처음 300자를 확인해본다.
print(train_text[:300])

#---------------------출력---------------------#
Length of text: 6937271 characters
Length of text: 2318260 characters

id    document    label
9976970    아 더빙.. 진짜 짜증나네요 목소리    0
3819312    흠...포스터보고 초딩영화줄....오버연기조차 가볍지 않구나    1
10265843    너무재밓었다그래서보는것을추천한다    0
9045019    교도소 이야기구먼 ..솔직히 재미는 없다..평점 조정    0
6483659    사이몬페그의 익살스런 연기가 돋보였던 영화!스파이더맨에서 늙어보이기만 했던 커스틴 던스트가 너무나도 이뻐보였다    1
5403919    막 걸음마 뗀 3세부터 초등학교 1학년생인 8살용영화.ㅋㅋㅋ...별반개도 아까움.    0
7797314    원작의

정답 데이터(Y) 전처리

첫 번째 줄과 두 번째 줄은 각 텍스트를 개행 문자(\n)로 분리한 다음에 헤더에 해당하는 부분(id document label)을 제외한 나머지([1:])에 대해 각 행을 처리한다. 각 행은 탭 문자(\t)로 나눠진 후에 2번째 원소를 정수로 변환해서 저장한다.

train_Y = np.array([[int(row.split('\\t')[2])] for row in train_text.split('\\n')[1:]
                    if row.count('\\t') > 0])
test_Y = np.array([[int(row.split('\\t')[2])] for row in test_text.split('\\n')[1:]
                    if row.count('\\t') > 0])
print(train_Y.shape, test_Y.shape)
print(train_Y[:5])
#---------------------출력---------------------#
(150000, 1) (50000, 1)
[[0]
 [1]
 [0]
 [0]
 [1]]

훈련 데이터의 입력(X) 정제

입력으로 쓸 자연어를 토큰화(Tokenization)하고, 정제(Cleaning)해야 한다. 토큰화란 자연어를 처리 가능한 작은 단위로 나누는 것으로, 여기서는 단어를 사용할 것이기 때문에 띄어쓰기 단위로 나누면 된다. 정제란 원하지 않는 입력이나 불필요한 기호 등을 제거하는 것이다.

import re
# From <https://github.com/yoonkim/CNN_sentence/blob/master/process_data.py>
# re.sub('패턴', '바꿀문자열', '문자열', 바꿀횟수) : 문자열에 있는 패턴을 바꿀문자열로 변환
def clean_str(string):
  string = re.sub(r"[^가-힣A-Za-z0-9(),!?\\'\\`]", " ", string)     
  string = re.sub(r"\\'s", " \\'s", string) 
  string = re.sub(r"\\'ve", " \\'ve", string) 
  string = re.sub(r"n\\'t", " n\\'t", string) 
  string = re.sub(r"\\'re", " \\'re", string) 
  string = re.sub(r"\\'d", " \\'d", string) 
  string = re.sub(r"\\'ll", " \\'ll", string) 
  string = re.sub(r",", " , ", string) 
  string = re.sub(r"!", " ! ", string) 
  string = re.sub(r"\\(", " \\( ", string) 
  string = re.sub(r"\\)", " \\) ", string) 
  string = re.sub(r"\\?", " \\? ", string) 
  string = re.sub(r"\\s{2,}", " ", string) 
  string = re.sub(r"\\'{2,}", "\\'", string) 
  string = re.sub(r"\\'", "", string) 

  return string.lower()

train_text_X = [row.split('\\t')[1] for row in train_text.split('\\n')[1:] 
                if row.count('\\t') > 0]
train_text_X = [clean_str(sentence) for sentence in train_text_X]
sentences = [sentence.split(' ') for sentence in train_text_X]
for i in range(5):
  print(sentences[i])
#---------------------출력---------------------#
['아', '더빙', '진짜', '짜증나네요', '목소리']
['흠', '포스터보고', '초딩영화줄', '오버연기조차', '가볍지', '않구나']
['너무재밓었다그래서보는것을추천한다']
['교도소', '이야기구먼', '솔직히', '재미는', '없다', '평점', '조정']
['사이몬페그의', '익살스런', '연기가', '돋보였던', '영화', '!', '스파이더맨에서', '늙어보이기만', '했던', '커스틴', '던스트가', '너무나도', '이뻐보였다']

각 문장의 단어 길이 확인

네트워크에 입력하기 위한 데이터의 크기는 동일해야 하는데 현재는 각 문장의 길이가 다르기 때문에 문장의 길이를 맞춰야 한다. 이를 위해서는 적당한 길이의 문장이 어느 정도인지 확인하고, 긴 문장은 줄이고 짧은 문장에는 공백을 의미하는 패딩(padding)을 채워넣어야 한다. 각 문장의 길이를 그래프로 그려본다.

그래프의 Y축은 문장의 단어 개수이다. 15만개 문장 중에서 25 단어 이하인 문장수는 전체의 95% 정도이다. 따라서 기준이 되는 문장의 길이를 25단어로 잡고, 이 이상은 생략, 이 이하는 패딩으로 길이를 25로 맞춰준다.

import matplotlib.pyplot as plt
sentence_len = [len(sentence) for sentence in sentences]
sentence_len.sort()
plt.plot(sentence_len)
plt.show()

단어 정제 및 문장 길이 줄임

문장의 최대 단어수 뿐만아니라 각 단어의 최대 길이도 조정해주어야 한다. 훈련 데이터의 5번째 문장에는 '스파이더맨에서'라는 단어가 있다. 이 단어와 '스파이더맨', '스파이더맨이', '스파이더맨을' 등의 단어가 나온다고 가정할 때 모두 스파이더맨이라는 단어로 바꿔도 의미가 어느정도 보존된다. 여기서는 단어의 길이를 최대 5로 설정해준다.

다음 코드에서는 문장의 최대 단어수와 단어의 최대 길이를 조정한다.

sentences_new = []
for sentence in sentences:
  sentences_new.append([word[:5] for word in sentence][:25])
sentences = sentences_new
for i in range(5):
  print(sentences[i])
#---------------------출력---------------------#
['아', '더빙', '진짜', '짜증나네요', '목소리']
['흠', '포스터보고', '초딩영화줄', '오버연기조', '가볍지', '않구나']
['너무재밓었']
['교도소', '이야기구먼', '솔직히', '재미는', '없다', '평점', '조정']
['사이몬페그', '익살스런', '연기가', '돋보였던', '영화', '!', '스파이더맨', '늙어보이기', '했던', '커스틴', '던스트가', '너무나도', '이뻐보였다']

패딩 설정 및 빈도수 높은 단어 추출

짧은 문장을 같은 길이의 문장(25단어)으로 바꾸기 위해 패딩을 넣는다. 또 모든 단어를 사용하지 않고, 출현 빈도가 가장 높은 일부 단어만 추출해본다.

from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

tokenizer = Tokenizer(num_words=20000) 
tokenizer.fit_on_texts(sentences) # 빈도수가 높은 20000개의 단어 인덱싱
train_X = tokenizer.texts_to_sequences(sentences) # 텍스트 문장을 숫자로 변환
train_X = pad_sequences(train_X, padding='post') # 패딩을 문장 뒤쪽에 더함

print(train_X[:5])
#---------------------출력---------------------#
[[   25   884     8  5795  1111     0     0     0     0     0     0     0
      0     0     0     0     0     0     0     0     0     0     0     0
      0]
 [  588  5796  6697     0     0     0     0     0     0     0     0     0
      0     0     0     0     0     0     0     0     0     0     0     0
      0]
 [    0     0     0     0     0     0     0     0     0     0     0     0
      0     0     0     0     0     0     0     0     0     0     0     0
      0]
 [   71   346    31    35 10468     0     0     0     0     0     0     0
      0     0     0     0     0     0     0     0     0     0     0     0
      0]
 [  106  5338     4     2  2169   869   573     0     0     0     0     0
      0     0     0     0     0     0     0     0     0     0     0     0
      0]]

모델 구현

임베딩 레이어에서는 input_length 인수를 25로 지정해서 각 문장에 들어있는 25개의 단어를 길이 300의 임베딩 벡터로 변환한다.

네트워크의 loss는 sparse_categorical_crossentropy를 사용했다. 분류 문제일 때 categorical_crossentropy를 사용하고, sparse는 정답인 Y가 희소 행렬일 때 사용한다.

model = tf.keras.Sequential([
            tf.keras.layers.Embedding(20000, 300, input_length=25),
            tf.keras.layers.LSTM(units=50),
            tf.keras.layers.Dense(2, activation='softmax')
])
#---------------------출력---------------------#
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', 
                                    metrics=['accuracy'])
model.summary()

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        (None, 25, 300)           6000000   
_________________________________________________________________
lstm (LSTM)                  (None, 50)                70200     
_________________________________________________________________
dense (Dense)                (None, 2)                 102       
=================================================================
Total params: 6,070,302
Trainable params: 6,070,302
Non-trainable params: 0
_________________________________________________________________

모델 학습

history = model.fit(train_X, train_Y, epochs=5, batch_size=128, validation_split=0.2)

학습 그래프

학습이 진행될 수록 val_loss는 올라가고, val_accuracy는 떨어진다. 이는 네크워크가 오버피팅 되고 있는 것이다. 오버피팅의 이유는 임베딩 레이어를 랜덤한 값에서부터 시작해서 학습시키기 때문에 각 단어를 나타내는 벡터의 품질이 좋지 않아서 이다. 이를 개선하기 위한 방법으로 임베딩 레이어를 별도로 학습시켜서 네트워크에 불러와서 사용하거나 RNN이 아닌 CNN을 사용하는 방법이 있다.


평가

테스트의 정확도는 80%로 나왔고, 검증데이터의 정확도와 비슷한 값이다.

test_text_X = [row.split('\\t')[1] for row in test_text.split('\\n')[1:] if row.count('\\t')>0]
test_text_X = [clean_str(sentence) for sentence in test_text_X]
sentences = [sentence.split(' ') for sentence in test_text_X]
sentences_new = []
for sentence in sentences:
  sentences_new.append([word[:5] for word in sentence][:25])
sentences = sentences_new

test_X = tokenizer.texts_to_sequences(sentences)
test_X = pad_sequences(test_X, padding='post')

model.evaluate(test_X, test_Y, verbose=0)
#---------------------출력---------------------#
[0.5488167405128479, 0.8022000193595886]

임의의 문장에 대한 감성 분석 결과

순환 신경망이 입력의 변화에 따라 값이 변한다는 것을 확인하기 위해 하나의 문장을 잘라서 앞에서 부터 차례로 입력해본다.

처음의 '재미있을' 이라는 단어만 입력되었을 때는 긍정의 확률이 62.8%로 부정보다 높다. 이후 다른 단어들이 입력되면 꾸준히 줄어든다.

test_sentence = '재미있을 줄 알았는데 완전 실망했다. 너무 졸리고 돈이 아까웠다.'
test_sentence = test_sentence.split(' ')
test_sentences = []
now_sentence = []
for word in test_sentence:
  now_sentence.append(word)
  test_sentences.append(now_sentence[:])

test_X_1 = tokenizer.texts_to_sequences(test_sentences)
test_X_1 = pad_sequences(test_X_1, padding='post', maxlen=25)
prediction = model.predict(test_X_1)
for idx, sentence in enumerate(test_sentences):
  print(sentence)
  print(prediction[idx])
#---------------------출력---------------------#
['재미있을']
[0.37120554 0.62879443]
['재미있을', '줄']
[0.5215692  0.47843084]
['재미있을', '줄', '알았는데']
[0.61325896 0.38674104]
['재미있을', '줄', '알았는데', '완전']
[0.6718411 0.3281589]
['재미있을', '줄', '알았는데', '완전', '실망했다.']
[0.6718411 0.3281589]
['재미있을', '줄', '알았는데', '완전', '실망했다.', '너무']
[0.70785517 0.29214483]
['재미있을', '줄', '알았는데', '완전', '실망했다.', '너무', '졸리고']
[0.9927061  0.00729383]
['재미있을', '줄', '알았는데', '완전', '실망했다.', '너무', '졸리고', '돈이']
[9.990013e-01 9.986858e-04]
['재미있을', '줄', '알았는데', '완전', '실망했다.', '너무', '졸리고', '돈이', '아까웠다.']
[9.990013e-01 9.986858e-04]


자연어 생성

한글 원본 텍스트를 자소 단위와 단어 단위로 나눠서 순환 신경망으로 생성해 본다.


단어 단위 생성

데이터셋 준비

여기서는 국사편찬위원회에서 제공하는 조선왕조실록 국문 번역본을 사용합니다.

path_to_file = tf.keras.utils.get_file('input.txt', '<http://bit.ly/2Mc3S0V>')

데이터 로드 및 확인

총 26,000,000자 정도이고, 한자도 섞여있다. 정제를 할 때 한자도 함께 제거해주고, 한글만 남긴다.

# 데이터를 메모리에 불러옵니다. encoding 형식으로 utf-8 을 지정해야합니다.
train_text = open(path_to_file, 'rb').read().decode(encoding='utf-8')

# 텍스트가 총 몇 자인지 확인합니다.
print('Length of text: {} characters'.format(len(train_text)))
print()

# 처음 100 자를 확인해봅니다.
print(train_text[:100])
#---------------------출력---------------------#
Length of text: 26265493 characters

태조 이성계 선대의 가계. 목조 이안사가 전주에서 삼척·의주를 거쳐 알동에 정착하다 
태조 강헌 지인 계운 성문 신무 대왕(太祖康獻至仁啓運聖文神武大王)의 성은 이씨(李氏)요, 휘

입력 데이터 정제

import re
# From <https://github.com/yoonkim/CNN_sentence/blob/master/process_data.py>
def clean_str(string):    
    string = re.sub(r"[^가-힣A-Za-z0-9(),!?\\'\\`]", " ", string)
    string = re.sub(r"\\'ll", " \\'ll", string)
    string = re.sub(r",", " , ", string)
    string = re.sub(r"!", " ! ", string)
    string = re.sub(r"\\(", "", string)
    string = re.sub(r"\\)", "", string)
    string = re.sub(r"\\?", " \\? ", string)
    string = re.sub(r"\\s{2,}", " ", string)
    string = re.sub(r"\\'{2,}", "\\'", string)
    string = re.sub(r"\\'", "", string)

    return string

train_text = train_text.split('\\n')
train_text = [clean_str(sentence) for sentence in train_text]
train_text_X = []
for sentence in train_text:
    train_text_X.extend(sentence.split(' '))
    train_text_X.append('\\n')

train_text_X = [word for word in train_text_X if word != '']

print(train_text_X[:20])
#---------------------출력---------------------#
['태조', '이성계', '선대의', '가계', '목조', '이안사가', '전주에서', '삼척', '의주를', 
'거쳐', '알동에', '정착하다', '\\n', '태조', '강헌', '지인', '계운', '성문', '신무', '대왕']

단어 토큰화

여기서는 Tokenizer를 쓰지 않고, 직접 토큰화 한다. 단어의 수가 너무 많고, 빈도수로 정렬하는 Tokenizer를 쓰면 시간이 오래 걸린다.

# 단어의 set을 만듭니다.'
vocab = sorted(set(train_text_X))
vocab.append('UNK') # 텍스트에 존재하지 않는 토큰을 나타내는 U'NK'
print('{} unique words'.format(len(vocab)))

# vocab list를 숫자로 매핑하고, 반대도 실행한다.
word2idx = {u:i for i, u in enumerate(vocab)}
idx2word = np.array(vocab)

text_as_int = np.array([word2idx[c] for c in train_text_X])

# word2idx의 일부를 알아보기 쉽게 출력
print('{')
for word,_ in zip(word2idx, range(10)):
  print('  {:4s}: {:3d}'.format(repr(word), word2idx[word]))
#---------------------출력---------------------#
332640 unique words
{
  '\\n':   0
  '!' :   1
  ',' :   2
  '000명으로':   3
  '001':   4
  '002':   5
  '003':   6
  '004':   7
  '005':   8
  '006':   9

잘 토큰화가 되는지 확인해본다.

print(train_text_X[:10])
print(text_as_int[:10])

['태조', '이성계', '선대의', '가계', '목조', '이안사가','전주에서', '삼척','의주를','거쳐']
[299305 229634 161443  17430 111029 230292 251081 155087 225462  29027]

학습을 위한 데이터셋 생성

여기서는 tf.data.Dataset을 이용한다. Dataset의 장점은 간단한 코드로 데이터 섞기, 배치 수만큼 자르기, 다른 Dataset에 매핑하기 등을 빠르게 수행할 수 있다는 것이다.


여기서는 seq_length를 25로 설정해서 25개의 단어가 주어졌을 때 다음 단어를 예측하도록 데이터를 만든다. batch() 함수는 Dataset에서 한번에 반환하는 데이터의 숫자를 지정한다. 그래서 seq_length+1을 지정했다. drop_remainder=True 옵션으로 남는 부분은 버리도록 했다.

seq_length = 25
examples_per_epoch = len(text_as_int) // seq_length
sentence_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)
sentence_dataset = sentence_dataset.batch(seq_length+1, drop_remainder=True)
for item in sentence_dataset.take(1):
  print(idx2word[item.numpy()])
  print(item.numpy())
#---------------------출력---------------------#
['태조' '이성계' '선대의' '가계' '목조' '이안사가' '전주에서' '삼척' '의주를' '거쳐' 
'알동에' '정착하다' '\\n' '태조' '강헌' '지인' '계운' '성문' '신무' '대왕' '의' 
'성은' '이씨' '요' ',' '휘']
[299305 229634 161443  17430 111029 230292 251081 155087 225462  29027
 190295 256129      0 299305  25624 273553  36147 163996 180466  84413
 224182 164549 230248 210912      2 330313]

26개 단어가 한번에 묶여있음으로 각각 입려과 정답으로 묶어서 ([25단어], 1단어) 형태의 데이터를 만든다.

def split_input_target(chunk):
    return [chunk[:-1], chunk[-1]]

train_dataset = sentence_dataset.map(split_input_target)
for x,y in train_dataset.take(1):
    print(idx2word[x.numpy()])
    print(x.numpy())
    print(idx2word[y.numpy()])
    print(y.numpy())
#---------------------출력---------------------#
['태조' '이성계' '선대의' '가계' '목조' '이안사가' '전주에서' '삼척' '의주를' '거쳐' '알동에' '정착하다'
 '\\n' '태조' '강헌' '지인' '계운' '성문' '신무' '대왕' '의' '성은' '이씨' '요' ',']
[299305 229634 161443  17430 111029 230292 251081 155087 225462  29027
 190295 256129      0 299305  25624 273553  36147 163996 180466  84413
 224182 164549 230248 210912      2]
휘
330313

이후 Dataset의 데이터를 섞고, batch size를 다시 설정한다. 한번에 모든 데이터를 섞지 않고, BUFFER_SIZE 만큼 섞는다.

BATCH_SIZE = 128
steps_per_epoch = examples_per_epoch // BATCH_SIZE
BUFFER_SIZE = 10000

train_dataset = train_dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)

모델 생성

total_words = len(vocab)
model = tf.keras.Sequential([
            tf.keras.layers.Embedding(total_words, 100, input_length=seq_length),
            tf.keras.layers.LSTM(units=100, return_sequences=True),
            tf.keras.layers.Dropout(0.2),
            tf.keras.layers.LSTM(units=100),
            tf.keras.layers.Dense(total_words, activation='softmax')
])

model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
model.summary()
#---------------------출력---------------------#
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        (None, 25, 100)           33264000  
_________________________________________________________________
lstm (LSTM)                  (None, 25, 100)           80400     
_________________________________________________________________
dropout (Dropout)            (None, 25, 100)           0         
_________________________________________________________________
lstm_1 (LSTM)                (None, 100)               80400     
_________________________________________________________________
dense (Dense)                (None, 332640)            33596640  
=================================================================
Total params: 67,021,440
Trainable params: 67,021,440
Non-trainable params: 0
_________________________________________________________________

모델 학습

from tensorflow.keras.preprocessing.sequence import pad_sequences

def testmodel(epoch, logs):
  if epoch % 5 != 0 and epoch != 49:
    return
  test_sentence = train_text[0]

  next_words = 100
  for _ in range(next_words):
    test_text_X = test_sentence.split(' ')[-seq_length:]
    test_text_X = np.array([word2idx[c] if c in word2idx else word2idx['UNK'] for c in test_text_X])
    test_text_X = pad_sequences([test_text_X], maxlen=seq_length, padding='pre', value=word2idx['UNK'])

    output_idx = model.predict_classes(test_text_X)
    test_sentence += ' ' + idx2word[output_idx[0]]

    print()
    print(test_sentence)
    print()

testmodelcb = tf.keras.callbacks.LambdaCallback(on_epoch_end=testmodel)

history = model.fit(train_dataset.repeat(), epochs=50, steps_per_epoch=steps_per_epoch,
                    callbacks=[testmodelcb], verbose=2)

평가

난중일기의 한 구절을 넣어서 잘 생성하는지 확인해본다.

from tensorflow.keras.preprocessing.sequence import pad_sequences
test_sentenct = '동헌에 나가 공무를 본 후 활 십오 순을 쏘았다'

next_words = 100
for _ in range(next_words):
  test_text_X = test_sentence.split(' ')[-seq_length:]
  test_text_X = np.array([word2idx[c] if c in word2idx else total_words+1 for c in test_text_X])
  test_text_X = pad_sequences([test_text_X], maxlen=seq_length, padding='pre', value=word2idx['UNK'])

  output_idx = model.predict_classes(test_text_X)
  test_sentence += ' ' + idx2word[output_idx[0]]

print(test_sentence)


자소 단위 생성

jamotools 설치

자소 단위 생성을 하기 위해서는 한글을 자소 단위로 분리하고 다시 합칠 수 있는 라이브러리가 필요하다. 이러한 작업을 할 수 있는 라이브러리로 jamotools가 있다.

!pip install jamotools

자모 분리 테스트

import jamotools

train_text = open(path_to_file, 'rb').read().decode(encoding='utf-8')
s = train_text[:100]
print(s)

# 한글 텍스트를 자모 단위로 분리. 한자 등에는 영향이 없다.
s_split = jamotools.split_syllables(s)
print(s_split)

#---------------------출력---------------------#
태조 이성계 선대의 가계. 목조 이안사가 전주에서 삼척·의주를 거쳐 알동에 정착하다 
태조 강헌 지인 계운 성문 신무 대왕(太祖康獻至仁啓運聖文神武大王)의 성은 이씨(李氏)요, 휘
ㅌㅐㅈㅗ ㅇㅣㅅㅓㅇㄱㅖ ㅅㅓㄴㄷㅐㅇㅢ ㄱㅏㄱㅖ. ㅁㅗㄱㅈㅗ ㅇㅣㅇㅏㄴㅅㅏㄱㅏ ㅈㅓㄴㅈㅜㅇㅔㅅㅓ ㅅㅏㅁㅊㅓㄱ·ㅇㅢㅈㅜㄹㅡㄹ ㄱㅓㅊㅕ ㅇㅏㄹㄷㅗㅇㅇㅔ ㅈㅓㅇㅊㅏㄱㅎㅏㄷㅏ 
ㅌㅐㅈㅗ ㄱㅏㅇㅎㅓㄴ ㅈㅣㅇㅣㄴ ㄱㅖㅇㅜㄴ ㅅㅓㅇㅁㅜㄴ ㅅㅣㄴㅁㅜ ㄷㅐㅇㅘㅇ(太祖康獻至仁啓運聖文神武大王)ㅇㅢ ㅅㅓㅇㅇㅡㄴ ㅇㅣㅆㅣ(李氏)ㅇㅛ, ㅎㅟ

자모 결합 테스트

분리했던 자모들이 다시 잘 합쳐지는 것을 볼 수 있다.

s2 = jamotools.join_jamos(s_split)
print(s2)
print(s == s2)
#---------------------출력---------------------#
태조 이성계 선대의 가계. 목조 이안사가 전주에서 삼척·의주를 거쳐 알동에 정착하다 
태조 강헌 지인 계운 성문 신무 대왕(太祖康獻至仁啓運聖文神武大王)의 성은 이씨(李氏)요, 휘
True

자모 토큰화

따로 텍스트 전처리를 하지 않기 때문에, 괄호, 한자 등이 토큰에 모두 포함된다. 총 6198개가 나오는데 이는 단어로 했을 때보다는 훨씬 적은 수치이다.

# 텍스트를 자모 단위로 나눈다.
train_text_X = jamotools.split_syllables(train_text)
vocab = sorted(set(train_text_X))
vocab.append('UNK')
print('{} unique characters'.format(len(vocab)))

# vocab list를 숫자로 매핑하고, 반대도 실행한다.
char2idx = {u:i for i, u in enumerate(vocab)}
idx2char = np.array(vocab)

text_as_int = np.array([char2idx[c] for c in train_text_X])

# word2idx의 일부를 알아보기 쉽게 출력한다.
print('{')
for char,_ in zip(char2idx, range(10)):
  print('   {:4s}:  {:3d},'.format(repr(char), char2idx[char]))
print('  ...\\n')

print('index of UNK: {}'.format(char2idx['UNK']))
#---------------------출력---------------------#
{
   '\\n':    0,
   ' ' :    1,
   '!' :    2,
   '"' :    3,
   "'" :    4,
   '(' :    5,
   ')' :    6,
   '+' :    7,
   ',' :    8,
   '-' :    9,
  ...

index of UNK: 6197

학습 데이터셋 생성

seq_length = 80
examples_per_epoch = len(text_as_int) // seq_length
char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int)

char_dataset = char_dataset.batch(seq_length+1, drop_remainder=True)

def split_input_target(chunk):
    return [chunk[:-1], chunk[-1]]

train_dataset = char_dataset.map(split_input_target)
for x,y in train_dataset.take(1):
    print(idx2char[x.numpy()])
    print(x.numpy())
    print(idx2char[y.numpy()])
    print(y.numpy())

BATCH_SIZE = 256
steps_per_epoch = examples_per_epoch // BATCH_SIZE
BUFFER_SIZE = 10000

train_dataset = train_dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)
#---------------------출력---------------------#
['\\ufeff' 'ㅌ' 'ㅐ' 'ㅈ' 'ㅗ' ' ' 'ㅇ' 'ㅣ' 'ㅅ' 'ㅓ' 'ㅇ' 'ㄱ' 'ㅖ' ' ' 'ㅅ' 'ㅓ' 'ㄴ'
 'ㄷ' 'ㅐ' 'ㅇ' 'ㅢ' ' ' 'ㄱ' 'ㅏ' 'ㄱ' 'ㅖ' '.' ' ' 'ㅁ' 'ㅗ' 'ㄱ' 'ㅈ' 'ㅗ' ' ' 'ㅇ'
 'ㅣ' 'ㅇ' 'ㅏ' 'ㄴ' 'ㅅ' 'ㅏ' 'ㄱ' 'ㅏ' ' ' 'ㅈ' 'ㅓ' 'ㄴ' 'ㅈ' 'ㅜ' 'ㅇ' 'ㅔ' 'ㅅ' 'ㅓ'
 ' ' 'ㅅ' 'ㅏ' 'ㅁ' 'ㅊ' 'ㅓ' 'ㄱ' '·' 'ㅇ' 'ㅢ' 'ㅈ' 'ㅜ' 'ㄹ' 'ㅡ' 'ㄹ' ' ' 'ㄱ' 'ㅓ'
 'ㅊ' 'ㅕ' ' ' 'ㅇ' 'ㅏ' 'ㄹ' 'ㄷ' 'ㅗ' 'ㅇ']
[6158   83   87   79   94    1   78  106   76   90   78   56   93    1
   76   90   59   62   87   78  105    1   56   86   56   93   10    1
   72   94   56   79   94    1   78  106   78   86   59   76   86   56
   86    1   79   90   59   79   99   78   91   76   90    1   76   86
   72   81   90   56   36   78  105   79   99   64  104   64    1   56
   90   81   92    1   78   86   64   62   94   78]
ㅇ
78

모델 생성

total_chars = len(vocab)
model = tf.keras.Sequential([
    tf.keras.layers.Embedding(total_chars, 100, input_length=seq_length),
    tf.keras.layers.LSTM(units=400),
    tf.keras.layers.Dense(total_chars, activation='softmax')
])

model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
model.summary()
#---------------------출력---------------------#
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_1 (Embedding)      (None, 80, 100)           619800    
_________________________________________________________________
lstm_2 (LSTM)                (None, 400)               801600    
_________________________________________________________________
dense_1 (Dense)              (None, 6198)              2485398   
=================================================================
Total params: 3,906,798
Trainable params: 3,906,798
Non-trainable params: 0
_________________________________________________________________

학습

초반에는 반복되는 패턴이 자주 나타나지만 점점 그럴듯한 결과가 만들어진다. 정확도는 점점 올라가고, loss는 점점 낮아진다.

from tensorflow.keras.preprocessing.sequence import pad_sequences

def testmodel(epoch, logs):
    if epoch % 5 != 0 and epoch != 99:
        return

    test_sentence = train_text[:48]
    test_sentence = jamotools.split_syllables(test_sentence)

    next_chars = 300
    for _ in range(next_chars):
        test_text_X = test_sentence[-seq_length:]
        test_text_X = np.array([char2idx[c] if c in char2idx else char2idx['UNK'] for c in test_text_X])
        test_text_X = pad_sequences([test_text_X], maxlen=seq_length, padding='pre', value=char2idx['UNK'])

        output_idx = model.predict_classes(test_text_X)
        test_sentence += idx2char[output_idx[0]]

    print()
    print(jamotools.join_jamos(test_sentence))
    print()

testmodelcb = tf.keras.callbacks.LambdaCallback(on_epoch_end=testmodel)

history = model.fit(train_dataset.repeat(), epochs=100, steps_per_epoch=steps_per_epoch, callbacks=[testmodelcb], verbose=2)
#---------------------출력---------------------#
Epoch 1/100
태조 이성계 선대의 가계. 목조 이안사가 전주에서 삼척·의주를 거쳐 알동에 정착하다 
이를 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 
것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 
것을 것을 것을 것을 것을 것을 것을 것을 것을 것을 것을
2364/2364 - 270s - loss: 2.5904 - accuracy: 0.3065

...
(중간생략)
...

Epoch 100/100

태조 이성계 선대의 가계. 목조 이안사가 전주에서 삼척·의주를 거쳐 알동에 정착하다 하여, 일이 있으면, 중국의 음사운의 청한 것은 아뢰기를,
"각도의 아들을 보내어 여러 신하들이 이르기를, ‘군사의 임금이 말하기를,
"이박한 그렇게 한 것은 아뢰기를,
"각도에 이르렀던 것이 없습니다. 그러나 이에 가서 수령을 감동하다
사헌부에서 계하기를,
"원산군(
2364/2364 - 274s - loss: 0.9206 - accuracy: 0.6999

평가

뒤로 가면 비슷한 문장이 반복되긴 하지만 나름 잘 생성된다.

from tensorflow.keras.preprocessing.sequence import pad_sequences
test_sentence = '동헌에 나가 공무를 본 후 활 십오 순을 쏘았다'
test_sentence = jamotools.split_syllables(test_sentence)

next_chars = 300
for _ in range(next_chars):
    test_text_X = test_sentence[-seq_length:]
    test_text_X = np.array([char2idx[c] if c in char2idx else char2idx['UNK'] for c in test_text_X])
    test_text_X = pad_sequences([test_text_X], maxlen=seq_length, padding='pre', value=char2idx['UNK'])

    output_idx = model.predict_classes(test_text_X)
    test_sentence += idx2char[output_idx[0]]


print(jamotools.join_jamos(test_sentence))
#---------------------출력---------------------#
동헌에 나가 공무를 본 후 활 십오 순을 쏘았다. 임금이 말하기를,
"이보다 큰 공상은 그 집에 돌아오다
정사를 보았다. 임금이 말하기를,
"이방성을 아뢰다
함길도 감사가 이미 나라를 행하였다.상왕이 그 사람을 금하다
임금이 말하기를,
"이보다 큰 공상은 그 집에 돌아온다. 【모든 것을 보내어 여러 관원은 농산ㄱ