긴 시퀀스를 활용해서 RNN 신경망 모델로 구현하면 불안정한 그레디언트 문제가 발생합니다. 그레이던트의 소실, 폭주 혹은 긴 훈련 시간, 불안정한 훈련 등 다양한 문제에 봉착할 수 있습니다. 이런 긴 시퀀스를 처리하기 위해서 다양한 해결방법이 있지만 그 중 층 정규화에 대해 먼저 살펴보겠습니다.
층 정규화는 RNN 모델 각 층 사이에서 feature dimention에 대해 정규화하는 방법입니다. 즉, 각 타임 스텝 사이에서 층 정규화를 통해 긴 시퀀스 데이터를 학습할 때 생기는 정보 손실과 불안정한 그레디언트를 예방할 수 있습니다. 간단하게 구현해 보겠습니다.
class LNSimpleRNNCell(tf.keras.layers.Layer):
def __init__(self, units, activation='tanh', **kwargs):
super().__init__(**kwargs)
# state는 이전 타임 스템의 은닉층 상태를 담은 매개변수로서
# 하나 이상의 텐서를 담고 있다.
# 각 state_size와 output_size는 unit 개수로 맞춰준다
self.state_size = units
self.output_size = units
# SimpleRNN Cell을 구성하는데 활성화 함수가 없는 이유는
# 이전 스텝의 결과(output)와 현재 input(state, 은닉층)을 선형 연산 후 활성화 함수 이전에 층 정규화를 수행하기 위해서힙니다.
self.simple_rnn_cell = tf.keras.layers.SimpleRNN(units, activation=None)
# 각 타임 스텝마다 층 정규화가 이뤄질 수 있도록 설정
self.layer_norm = tf.keras.layer.LayerNormalization()
# 앞에서 설정하지 않았던 활성화 함수를 층 정규화 이후에 세팅합니다.
self.activation = tf.keras.activations.get(activation) # 탄젠트 함수를 activation으로 사용
def call(self, inputs, states):
# SimpleRNN 셀을 사용하여 현재 입력(inputs)과 이전 은닉 상태(states)의 선형 조합을 계산합니다.
# 여기서 출력은 은닉 상태와 동일합니다. 즉, new_stats[0] == outputs
# 나머지 new_stats는 무시해도 괜찮음
outputs, new_states = self.simple_rnn_cell(inputs, states)
# 층 정규화와 활성화 함수 차례대로 적용
norm_outputs = self.activation(self.layer_norm(outputs))
# 출력을 두번 반환
# 하나는 출력, 다른 하나는 새로운 은닉 상태
return norm_outputs, [norm_outputs]
# 위 사용자 정의 Cell을 사용하려면 tf.keras.layers.RNN 층을 만들어 이 class 객체를 전달하면 됩니다.
model = tf.keras.models.Sequential([
tf.keras.layers.RNN(LNSimpleRNNCell(20), return_sequences=True, input_shape=[None,1]),
tf.keras.layers.RNN(LNSimpleRNNCell(20), return_sequences=True),
tf.keras.layers.TimeDistributed(tf.keras.layers.Dense(10))
])
위처럼 층 정규화 방법 외에도 dropout 비율 정의를 통해서도 긴 시퀀스를 다루기 위해 불안정한 그레디언트 문제를 감소할 수 있습니다. 그럼, 이제 단기 기억 문제에 대해 알아보고 이에 대한 방안인 LSTM Cell에 대해 알아보겠습니다.
LSTM
RNN 모델에서 타임 스텝이 계속 진행할수록 처음 타임 스텝에서 가져온 시퀀스 데이터의 정보들이 소멸됩니다. 즉, 단기 기억에만 머물게 되는 문제가 발생합니다. 이 문제를 해결하기 위해 장기기억을 담당하는 Cell이 개발됐습니다. 바로 LSTM입니다.
장단기 메모리 셀 즉, LSTM Cell은 훈련을 빠르게 진행하고, 시퀀스 데이터의 장기간 의존성을 유지할 것입니다. keras에서 구현은 간단합니다.
# 간단히 LSTM Cell 사용
model =tf.keras.models.Sequential([
tf.keras.layers.LSTM(20, return_sequences=True, input_shape=[None, 1]),
tf.keras.layers.LSTM(20, return_sequences=True),
tf.keras.layers.TimeDistributed(tf.keras.layers.Dense(10))
])
# RNN 층에 LSTMCell을 매개변수로 지정
model = tf.keras.models.Sequential([
tf.keras.layers.RNN(tf.keras.layers.LSTMCell(20), return_sequences=True, inpit_shape=[None,1]),
tf.keras.layers.RNN(tf.keras.layers.LSTMCell(20), return_sequences=True)
tf.keras.layers.TimeDistributed(tf.keras.layers.Dense(10))
])
그런데 GPU 사용을 고려할 때, LSTMCell이 최적화 돼 있어서 일반적으로는 LSTMCell을 직접사용하고, 사용자 정의 Cell을 만들 때는 RNN layer를 사용하는게 일반적입니다.
LSTM Cell의 작동 구조는 매우 복잡합니다.
출처 : http://www.rex-ai.info/docs/AI_study_LSTM
키포인트는 LSTM 네트워크에서 장기 상태로 저장할 값, 버릴 값, 읽어드릴 값을 학습하는 것입니다. 이전 장기 기억인 (Cell) state는 삭제 게이트를 지나서 일부 정보를 손실하고, 입력 게이트를 지나 선택된 정보들을 추가합니다. 이렇게 만들어진 새로운 장기 기억은 다음 장기 기억(Next Cell State)으로 전달됩니다. 그래서 각 타임 스텝 마다 일부 정보는 손실되고, 추가됩니다. 또한 이 새롭게 만들어진 장기 기억은 tanh 활성화 함수를 거쳐, 출력 게이트를 통과합니다. 이렇게 해서 만들어진 단기 상태가 Output(\(h_t\)) 값으로 전달됩니다.
간략히 정리해보면 LSTM 네트워크는 중요한 입력은 인식하고, 장기 상태를 최적의 정보로 유지하기 위해 보존하고, 필요할 때마다 이를 추출하며 학습합니다. 이런 LSTM 모델은 긴 텍스트, 오디오 등 장기 패턴을 발견하는 데 우수한 성능을 보이고 있습니다.
LSTM 변종 GRU
GRU 셀은 LSTM 셀의 변종 버전으로 구조가 간소화되고, 유사하게 작동합니다. 간소화된 내용을 확인해 보면, LSTM 셀은 새로운 장기 기억과 은닉 상태 값 2개가 전달됐습니다. GRU는 이 2개를 합쳐서 하나의 벡터 값으로 전달됩니다. 그리고 하나의 게이트 제어기가 삭제 게이트와 입력 게이트 역할을 동시에 수행합니다. 마지막으로 출력 게이트가 없습니다. 즉, 전체 타임 스텝마다 해당 state 벡터가 출력됩니다. 그래서 새로운 게이트 제어기가 만들어졌는데 각 타임 스텝에서 생성한 state 벡터 중 어떤 값을 main layer에 노출시킬지 결정하는 게이트 제어기가 있습니다.
GRU 셀은 RNN의 대표적인 성공 모델입니다. 한가지 아쉬운 점은 기존 RNN, LSTM에 비해 긴 시퀀스를 잘 다루긴 하지만, 매우 제한적인 단기 기억을 가지고 있습니다. 이 문제를 해결하기 위한 방법은 다음 포스팅에서 소개하겠습니다.
지난 포스팅에 이어 심층 RNN으로 여러개의 타임 스텝을 예측하는 문제에 대해 다뤄보겠습니다. 지난 포스팅에서는 1개의 타임 스텝의 예측값을 출력한 후 그 값을 입력값에 적용해서 그 다음 스텝을 예측하는 방식으로 살펴봤는데요. 성능이 썩 좋지 않았습니다. 그래서 이번에는 다른 방법을 소개해 보겠습니다.
한 번에 10 스텝 예측하기
두 번째 방법으로 볼 수 있는데, 한 번에 예측하고 싶은 개수 만큼 예측하는 방법입니다. 이번 포스팅 예제에서는 10개의 타임 스텝 값을 한 번에 예측해보도록 코드를 구현해 보겠습니다. 원리는 기존 RNN의 모델과 같은 시퀀스-투-벡터 형식인데, 결과값 벡터가 이전에는 1개로 구성돼 있다면 이번에는 10개로 구성되게끔 모델을 생성하면 됩니다.
# 데이터 새롭게 구성
n_steps = 50
series = generate_time_series(10000, n_steps+10)
# 출력값 벡터가 10개인 모델 구성에 맞게 데이터도 재구성
train_x, train_y = series[:7000, :n_steps], series[:7000, -10:, 0]
val_x, val_y = series[7000:9000, :n_steps], series[7000:9000, -10:, 0]
test_x, test_y = series[9000: , :n_steps], series[9000: , -10:, 0]
#######################################################################
# 모델
model = tf.keras.models.Sequential([
tf.keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
tf.keras.layers.SimpleRNN(20),
# 다음 타임 스텝 10개를 한 번에 예측하기 위한 layer
tf.keras.layers.Dense(10)
])
model.compile(
optimizer = tf.keras.optimizers.Adam(),
loss = tf.keras.losses.MeanSquaredError(),
metrics = [tf.keras.metrics.MeanSquaredError()]
)
history = model.fit(
train_x,
train_y,
epochs=20,
validation_data = (val_x, val_y)
)
# MSE 확인
pred_y = model.predict(test_x)
print(np.mean(tf.keras.losses.mean_squared_error(test_y, pred_y)
# ==> 0.01045579
테스트 데이터로 평가해 본 결과 0.01로 이전보다 높은 성능을 보이고 있습니다. 하지만 여기서 더 개선할 부분이 있습니다. 현재 모델은 시퀀스-투-벡터 형식의 모델입니다. 이 말은 위에서 구성한 모든 타임 스텝의 결과를 반영하는 것이 아니라 제일 마지막 타입 스텝의 결과 값만을 반영해서 최종 출력이 나온다는 의미입니다. 그렇다면 이 모델을 시퀀스-투-시퀀스로 바꾸면 어떻게 될까요?
이 방식을 적용한다면, 마지막 타임 스텝에서 나오는 결과뿐만 아니라 모든 타임 스텝에서 출력되는 결과를 적용할 수 있습니다. 이 말은 더 많은 오차 그레디언트가 모델에 흐른다는 의미고, 시간에 구애 받지 않습니다. 즉, 각 타임 스텝의 출력에서 그레디언트가 적용될 수 있습니다.
좀 더 풀어서 설명하면, 타임 스텝 0에서 타임 스텝 1 ~ 10까지 예측을 담은 벡터를 출력합니다. 그 다음 타임 스텝 1에서 2 ~ 11까지 예측을 담은 벡터를 출력합니다. 각 타겟 값은 입력 시퀀스와 길이가 동일합니다. 즉, 타겟 시퀀스틑 각 타임 스텝마다 10차원 벡터를 출력합니다.
기본 RNN 모델을 시퀀스-투-시퀀스 모델로 바꾸려면 모든 순환 층의 결과 값을 출력하고 반영해야 합니다. 그래서 마지막 층에도 return_sequences=True 값을 부여해줍니다. 그런 다음 각 층에서 나온 출력값을 마지막 Dense Layer에 모두 적용해야 합니다. 이 부분을 가능하게 해주는 기능이 keras에서 TimeDistributed Layer를 제공합니다.
TimeDistributed Layer 작동 원리는 각 타임 스텝을 별개의 샘플처럼 다룰 수 있도록 입력 크기를 (배치 크기, 타임 스텝 수, 입력 차원) → (배치 크기 × 타임 스텝 수, 입력 차원) 으로 바꿉니다. 우리 코드에서는 SimpleRNN 유닛이 20개로 설정해서 입력 차원 수를 20으로 세팅한 후 Dense Layer에 적용됩니다. 그리고 Dense Layer에서 출력할 때는 다시 원래대로 (배치 크기, 타임 스텝 수, 입력 차원) 로 출력 크기가 변하고, Dense 유닛을 10이라고 지정해서 입력 차원을 10으로 세팅될 것입니다. 모델을 보겠습니다.
new_model = tf.keras.models.Sequential([
tf.keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None,1]),
tf.keras.layers.SimpleRNN(20, return_sequences=True),
tf.keras.layers.TimeDistributed(tf.keras.layers.Dense(10))
])
# 훈련 때는 모든 타임 스텝의 결과를 활용해서 MSE를 계산하는데
# 최종 실제 모델 평가 결과는 마지막 타임 스텝의 출력에 대한 MSE만 계산하면 되서 사용자 정의 지표를 만들 필요가 있다.
def last_time_step_mse(y_true, y_pred):
return tf.keras.metrics.mean_squared_error(y_true[:, -1], y_pred[:, -1])
new_model.compile(
optimizer = tf.keras.optimizers.Adam(lr=0.01),
loss = 'mse',
metrics = [last_time_step_mse]
)
new_history = new_model.fit(
train_x,
Y_trin,
epochs=20,
validation_data = (val_x, Y_valid)
)
# 최종 MSE 확인
print(new_history.history['val_last_time_step_mse'][-1])
# ==> 0.006069639232009649
성능이 확실히 개선된 결과를 확인할 수 있습니다. 이 RNN 구조를 사용해서 다음 타임 스텝 10개를 예측하고 이 출력값을 다시 입력 시계열에 연결해서 다시 다음 10 타임 스텝의 값을 예측하도록 세팅할 수 있습니다.
지금까지 RNN 신경망에 대해 알아봤습니다. 한계점도 존재하는데 길이가 긴 시계열 데이터나 시퀀스 에서는 잘 작동하지 않습니다. 이 문제를 어떻게 해결할 수 있는지 다음 포스팅에서 다뤄보겠습니다.
우리가 평가 기준으로 삼고 있는 MSE와 Loss의 변화 그래프입니다. 아주 예쁘게 좋은 성과를 보여주고 있습니다.
위 코드에서 RNN 층을 맨 마지막에 하나의 유닛으로 구성했습니다. 이는 결론이 하나의 값으로 귀결돼야 하기 때문입니다. 이 마지막 RNN층은 사실 타임 스텝이 넘어 갈 때 필요한 모든 정보를 하나로 축약해서 전달할 때 필요할 듯 싶습니다. 그래서 사실, 우리가 얻고 싶은 모델 결과에서 마지막 RNN층 보다는 다른 layer 쌓는 게 더 낳을 수도 있습니다. 왜냐면 RNN 층은 활성화 함수로 tanh 활성화 함수를 사용하기 때문에 다른 활성화 함수를 사용하는 layer를 넣는게 나을 수 있습니다. binary classification에서는 마지막 결론을 출력하는 층으로 Dense 층을 사용합니다.
import tensorflow as tf
model = tf.keras.models.Sequential([
tf.keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
# 마지막 Dense 층에 모든 정보를 전달하면서 다른 활성화 함수를 사용하기 위해
# RNN의 마지막 층은 return_sequences=True 를 지정하지 않습니다.
tf.keras.layers.SimpleRNN(20),
tf.keras.layers.Dense(1)
])
model.compile(
optimizer = tf.keras.optimizers.Adam(),
loss = tf.keras.losses.MeanSquaredError(),
metrics = [tf.keras.metrics.MeanSquaredError()]
)
history = model.fit(
train_x,
train_y,
epochs=20,
validation_data = (val_x, val_y)
)
결과는 앞에서 봤던 결과와 거의 동일하게 나옵니다. 이 모델로 훈련할 경우 빠르게 수렴하고 성능도 좋은 것으로 판단할 수 있습니다. 또한 출력층의 활성화 함수를 원하는 함수로 변동도 가능합니다.
1 이상 타임 스텝 예측
지금 까지는 바로 다음 타임 스텝의 값만을 예측해봤습니다. 그런데 10, 100, 1000 타임 스텝을 예측하고 싶을 때는 어떻게 해야할까요?
첫 번째 방법
pretrained model을 활용해서 다음 값을 예측한 후 다시 이 값을 입력값으로 넣어서 그 다음값을 예측하는 방식을 반복하면 여러 타임 스텝의 값을 예측할 수 있습니다.
새로운 데이터 예측 (심층 RNN 모델 활용)
# 새로운 데이터로 예측
new_series = generate_time_series(1, n_steps + 10)
new_x, new_y = new_series[:, :n_steps], new_series[:, n_steps:]
x = new_x
for step_ahead in range(10):
# 처음부터 하나씩 증가하면서 다음 타임 스텝을 예측 후 결과 값을 기존 값(3차원)에 추가할 수 있도록 shape 변환
recent_y_pred = model.predict(x[:, step_ahead:])[:, np.newaxis, :]
x = np.concatenate([x, recent_y_pred], axis=1)
y_prd = x[:, n_steps:]
# MSE 점수
np.mean(tf.keras.losses.mean_squared_error(new_y, y_prd))
# ==> 0.014788166
# 그래프 시각화
# 폰트 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
# 그래프 그리기
plt.plot(range(50), new_x[0].reshape(-1), 'c.-')
plt.plot(range(50,60), new_y.reshape(-1), 'mo-')
plt.plot(range(50,60), y_prd.reshape(-1), 'bx-')
plt.xlabel('t')
plt.ylabel('x(t)')
plt.legend(['학습','실제', '예측'])
plt.grid(True)
plt.show()
validation data 예측 (RNN 모델)
# 데이터 새로 다시 만들기
n_steps = 50
series = generate_time_series(10000, n_steps+10)
# train 데이터는 7000개로 구성하고 (7000, 50, 1) shape로 구성합니다.
train_x, train_y = series[:7000, :n_steps], series[:7000, -10:]
val_x, val_y = series[7000:9000, :n_steps], series[7000:9000, -10:]
test_x, test_y = series[9000: , :n_steps], series[9000: , -10:]
# 위 방식을 valistion data에 적용해보기
X = val_x
for step_ahead in range(10):
# 처음부터 하나씩 증가하면서 다음 타임 스텝을 예측 후 결과 값을 기존 값(3차원)에 추가할 수 있도록 shape 변환
recent_y_pred = model.predict(X[:, step_ahead:])[:, np.newaxis, :]
X = np.concatenate([X, recent_y_pred], axis=1)
Y_prd = X[:, n_steps:]
np.mean(tf.keras.losses.mean_squared_error(val_y, Y_prd))
# ==> 0.029840497
검증 데이터인 validation data에 적용할 경우 0.029 라는 error 값이 나옵니다. 기본 선형 모델로 적용해보면 어떻게 나올까요?
validation data 예측 (기본 선형 모델)
X = val_x
for step_ahead in range(10):
# 선형 모델인 linear_model 사용
recent_y_pred = linear_model.predict(X[:, step_ahead:])[:, np.newaxis, :]
X = np.concatenate([X, recent_y_pred], axis=1)
Y_prd = X[:, n_steps:]
np.mean(tf.keras.losses.mean_squared_error(val_y, Y_prd))
# ==> 0.06303842
0.06으로 error 값이 더 높게 나왔네요. RNN 모델이든 기본 선형 모델이든 이 방식으로는 높은 성능을 기대하기 어려울 것 같습니다. 그럼 어떻게 해결할 수 있을까요? 두 번째 방법은 다음 포스팅에서 다뤄보겠습니다.
RNN은 BPTT 전략으로 훈련하는데, BPTT란 Backpropagation through time 즉, 타임 스텝으로 네트워크를 펼치고 기본 역전파를 사용하는 방법입니다. 입력 데이터가 정방향으로 진행되면서 가중치를 계산하고 비용함수를 통해 출력 결과를 생성합니다. 그럼 여기서 사용된 그레디언트가 다시 역방향으로 돌아와 가중치를 업데이트 하면서 모델을 최적화 시킵니다.
지금까지 이론 내용에 대해서만 소개했는데 실제 시계열 데이터를 간단하게 샘플용으로 만들고 이 데이터를 가벼운 RNN 모델에 넣어서 어떻게 학습되는지 코드로 살펴보겠습니다.
import numpy as np
# batch_size 만큼 n_steps 길이의 여러 시계열 데이터 생성
# 시계열 데이터(텍스트 문장과 같은 다른 시퀀스 데이터들도)는 입력 특성으로 3D 배열 [배치 크기, 타임 스텝 수, 차원 수]로 나타낸다
def generate_time_series(batch_size, n_steps):
# random하게 0 ~ 1 사이의 실수를 3차원으로 만들어보겠습니다.
freq1, freq2, offsets1, offsets2 = np.random.rand(4, batch_size, 1)
# 시간축 데이터 생성
time = np.linspace(0,1,n_steps)
# 사인 곡선 1
series = 0.5 * np.sin((time - offsets1) * (freq1 * 10 + 10))
# 사인 곡선 2
series += 0.2 * np.sin((time - offsets2) * (freq2 * 20 + 20))
# noise data
series += 0.1 * (np.random.rand(batch_size, n_steps) - 0.5)
return series[...,np.newaxis].astype(np.float32)
위 함수를 활용해서 훈련 데이터 세트, 검증 데이터 세트, 평가 데이터 세트를 만들어 보겠습니다
n_steps = 50
# 총 10000개의 시계열 데이터를 생성하고, 타입 스탭은 50 + 1 로 생성합니다.
series = generate_time_series(10000, n_steps+1)
# train 데이터는 7000개로 구성하고 (7000, 50, 1) shape로 구성합니다.
train_x, train_y = series[:7000, :n_steps], series[:7000, -1]
val_x, val_y = series[7000:9000, :n_steps], series[7000:9000, -1]
test_x, test_y = series[9000: , :n_steps], series[9000: , -1]
우리 모델의 성능을 비교할 기준 성능을 세팅합니다. 단순하게 각 시계열의 마지막 값을 그대로 예측해보는 성능을 기준으로 세워보겠습니다
import tensorflow as tf
pred_y = val_x[:, -1]
# 평균 제곱 오차 (MSE) 확인
print(np.mean(tf.keras.losses.mean_squared_error(val_y, pred_y)
# ==> 0.021878457
또 다른 간단한 방법으로 Fully Connected Network 를 사용해서 MSE를 구할 수 있습니다. 특별하게 이 완전연결층 네트워크는 입력마다 1차원 특성 배열을 요구하기 때문에 Flatten 층을 추가해야 합니다. 시계열 데이터를 선형 조합으로 예측할 필요가 있어, 간단한 선형 회귀 모델을 구현해보겠습니다.
# 모델 생성
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(input_shape=[50,1]),
tf.keras.layers.Dense(1)
])
# 모델 컴파일
model.compile(
optimizer = tf.keras.optimizers.Adam(),
loss = tf.keras.losses.MeanSquaredError(),
metrics = [tf.keras.metrics.MeanSquaredError()]
)
# 학습
history = model.fit(
train_x,
train_y,
epochs= 20,
validation_data = (val_x, val_y)
)
#결론 (제일 마지막에 나온 MSE)
history.history['val_mean_squared_error'][-1]
# ==> 0.004973348695784807
결론을 확인해봤을 때, 위에서 세웠던 기준 성능보다 더 높은 성능을 보여줍니다(MSE가 낮을수록 높은 성능) 그럼, 기준 성능도 세웠으니깐 Simple RNN을 간단하게 구현해보겠습니다.
- RNN은 어떤 길이의 타임스텝도 처리가 가능해서 입력 시퀀스 길이 지정할 필요 없다 (input_shape =[None, 1])
- 기본적으로 하이퍼볼릭 탄젠트 활성화 함수 사용
- 초기 상태 \( h_{(hint)} \)를 0으로 설정하고 첫 번째 타임 스텝 \( x_{(0)} \)와 함께 하나의 순환 뉴런으로 전달
- 뉴런에서 이 값의 가중치 합을 계산하고 하이퍼볼릭 탄젠트 활성화 함수를 적용해서 첫번째 결과 값 \( y_{(0)} \)을 출력한다.
- 이 출력이 새로운 \( h_{0} \) 상태가 되고 새로운 상태는 다음 입력값인 \( x_{(1)} \)과 함께 순환 뉴런으로 전달
- 이 과정을 마지막 타임 스텝까지 반복
- 이 RNN은 마지막 값 \( y_{49} \)를 출력
- 모든 시계열에 대해 이 과정 동시에 수행
- keras의 RNN layer는 최종 출력만 반환. 타임 스텝마다 출력이 필요할 경우 return_sequence=True 지정
위에서 만든 모델은 앞서 만들었던 기본 선형 모델보다는 못한 성능을 보여줍니다. 아무래도 층이 1개이고, RNN의 특성상 은닉층에서 갱신되는 파라미터 수의 제약이 어느 정도 영향을 미친 것으로 예상합니다. 다음 포스팅에서 심층 RNN으로 이어서 성능을 개선시켜 보겠습니다.