[ 머신러닝 순한 맛 ] 시계열 데이터의 모든 것, RNN 정복하기!
어제보다 나은 사람이 되기

걱정보단 실행을, 그러나 계획적으로

Box World 자세히보기

AI/Hands-On Machine Learning 2판

[ 머신러닝 순한 맛 ] 시계열 데이터의 모든 것, RNN 정복하기!

Box형 2021. 7. 28. 23:13
반응형

  사는 데 더 나은 방법을 찾아라

- 엘빈 토플러 -

 코로나로 인해 가장 크게 떠오른 분야가 무엇이냐고 한다면, 저는 주식만한게 없다고 생각합니다. 우린 결국 돈을 벌고 싶고, 미래를 예측하여 주식 가격을 예측할 수 있다면 더할 나위가 없겠죠^^

 이미 월가를 비롯해 주식시장의 최전방에 위치해있는 기업들은 실제로 다양한 기술을 이용하여 주식 가격의 미래를 예측하여 이를 실제로 수익과 연결시킵니다. 이번 포스팅에서는 순환 신경망 RNN(Recurrent Neural Network)에 대해 알아보겠습니다.

 이 RNN은 우리가 그동안 봐왔던 대부분의 네트워크처럼 고정된 길이의 Input이 아닌 임의의 길이를 가진 시퀀스를 다룰 수 있습니다. 시퀀스의 예로는 문장, 문서, 오디오 샘플 등 가변적인 길이가 가진 것들을 예시로 들 수 있고, 이것은 자동 번역, Speech to Text 같은 자연어 처리(NLP)에 매우 유용합니다.

 

 

 

 


Recurrent Neuron and Recureent Layer

 지금까지 우리가 봐온 네트워크들에서 Input이라 하면, 출력층을 향해서 한 방향으로만 흘러가는 Feed-Forward Network였습니다. 그러나 지금부터 우리가 볼 RNN은 출력으로 나온 Output이 다시 입력으로 순환하여 들어가는 연결이 있다는 점에서 기존과 차이점을 보입니다.

  위 그림의 가장 왼쪽에 있는 것은 입력 $x$를 받아서 출력 $y$를 내보내고 이것을 다시 자기 자신에게 입력으로 보내는 가장 간단한 RNN입니다. 이것은 각 타임 스텝 $t$(하나의 단위 데이터가 들어올 때마다)마다 $x_{(t)}$와 이전 타임 스텝의 출력인 $y_{(t-1)}$을 입력으로 받습니다. 이때 가장 첫번째 타임 스텝에서는 이전 출력이 없기 때문에 $y_{(t-1)}$를 0으로 잡습니다.

 왼쪽의 가장 간단한 RNN이 매 타임스텝마다 $x$와 $y_{(t-1)}$를 받아 $y_{(t)}$를 내보내는 과정을 시간을 축으로 하여 위 그림의 오른쪽처럼 표현할 수 있습니다. 이를 시간에 따라 네트워크를 펼쳤다라고 말합니다.(즉 동일 뉴런을 타임 스텝마다 하나로 표현한 것입니다.)

  이러한 하나의 순환 뉴런 여러개가 모여 구성된 층(Layer)는 쉽게 만들 수 있습니다. 

 뉴런이 여러개라는 건 입력이 여러 개라는 것이기 때문에 이제 입력은 스칼라가 아닌 벡터 $x_(t)$가 되고, 이전 타임 스텝의 출력 $y_{(t)}$ 또한 벡터가 됩니다. (하나의 뉴런의 출력은 스칼라입니다)

 각 순환 뉴런은 두 개의 가중치 벡터를 가집니다. 하나는 입력 벡터 $x_(t)$, 다른 하나는 이전 타임 스텝의 출력 $y_{(t)}$을 위한 것입니다. 이것들을 각각 $W_x$, $W_y$라고 하겠습니다. 그렇다면 순환 뉴런의 출력 $y_{(t)}$는 다음 식으로 계산될 수 있습니다.

 $$는 Relu와 같은 활성화 함수입니다. 보통 RNN에서는 Relu보단 tanh 함수를 선호합니다. 

 여기에 더 나아가서 이번엔 입력이 벡터 단위가 아니라, 미니 배치로써 여러 벡터가 들어온다고 하면 이를 행렬 $X_{(t)}$로 만들어 출력을 한번에 계산할 수 있습니다.

 $Y_{(t)}$는 $X_{(t)}$와 $Y_{(t-1)}$을 입력으로 받는 함수이고,  $Y_{(t-1)}$는 $X_{(t-1)}$와 $Y_{(t-2)}$을 입력으로 받고, 다시  $Y_{(t-2)}$는 $X_{(t-2)}$와 $Y_{(t-3)}$을 입력으로 받게됩니다. 여기서 알 수 있는 점은 $Y_{(t)}$는 맨 처음 $t=0$에서부터 모든 입력을 받은 뉴런의 출력값이라는 점입니다.

 

 

 

 


Memory Cell

 타임 스텝 $t$에서 뉴런의 출력은 이전 타임 스텝의 모든 입력에 대한 함수이므로 이를 일종의 메모리 형태라고 할 수 있습니다. 그래서 타임 스텝에 걸쳐 형성된 어떤 상태를 보존하는 RNN의 구성 요소를 메모리 셀 간단하게 이라고 부릅니다. -

 하나의 순환 뉴런 Layer는 일반적으로 10 타임 스텝 내외를 학습할 수 있는 기본적인 셀이지만, 뒤에서 더욱 긴 패턴을 학습할 수 있는 강력한 셀에 대해 공부하게 됩니다.

 일반적으로 타임 스텝 $t$에서의 셀의 상태 $h_{(t)}$는 $t$에서의 Input과 이전 타임 스텝의 셀 $h_{(t-1)}$에 대한 함수입니다. 타임 스텝 $t$에서의 출력 $y_{(t)}$도 이전 $h$와 현재 Input에 대한 함수이므로, 기본적인 셀의 경우 출력 $y_{(t)}$와 $h_{(t)}$는 같다고 할 수 있습니다. 하지만 모든 종류의 셀에서 그런 것은 아닙니다.

 

 

 

 

 

 

 

 


Input Sequence and Output Sequence

  RNN에는 다양한 네트워크 구조가 있습니다. 크게 <(입력)-투-(출력) 네트워크>에서 입출력의 형태가 시퀀스냐, 벡터냐에 따라 구조의 이름이 결정됩니다. 시퀀스와 벡터를 나누는 기준은 간단합니다. 벡터가 여러개가 들어가거나 여러개가 나가면 시퀀스, 하나의 벡터만 들어가거나 나오면 벡터입니다.

- 시퀀스-투-시퀀스 네트워크(좌측 상단) ex) 주식가격 같은 시계열 데이터를 예측하는데 유용합니다. 최근 $0~N$ 일치의 주식 가격을 주입하면 네트워크는 하루 앞선 가격 즉 $1~N+1$일치 주식 가격을 출력해야 합니다.

- 시퀀스-투-벡터 네트워크(우측 상단) : 마지막 출력($Y_{(3)}$)을 제외한 나머지 출력을 모두 무시하는 네트워크 입니다. ex) 한 문장의 영화 리뷰가 있다고 할때, 문장을 구성하는 단어를 하나의 입력 벡터라고 생각할 수 있습니다.               이때 이 리뷰의 평점을 계산한다 할때, 각 단어(입력 벡터)가 들어갈 때마다 평점이 계산되어 나오는 것이 아니라, 모든 단어(시퀀스)가 들어간 후에 최종적인 한번의 평점(벡터)이 출력되는 것입니다.

- 벡터-투-시퀀스 네트워크 ex) 하나의 이미지(입력 벡터)를 넣어주면 이미지에 대한 설명문(문장은 단어로 구성된 시퀀스이다)으로 출력할 수 있습니다.

- (우측 하단) : 마지막은 인코더라 부르는 시퀀스-투-벡터 네트워크 뒤에 디코더라 부르는 벡터-투-시퀀스 네트워크를 연결한 구조입니다. ex) 한국말을 영어로 번역하는데 사용될 수 있습니다. 즉 한국어 문장으 주입하면 인코더는 이를 하나의 벡터 표현으로 변환하고 디코더가 이 벡터를 영어 문장으로 디코딩합니다.

 이러한 인코더-디코더 모델은 시퀀스-투-시퀀스 RNN을 사용하여 한 단어씩 번역하는 것보다 훨씬 더 잘 작동합니다. 왜냐하면 RNN의 경우엔 문장의 마지막 단어가 번역의 첫번째 단어에 영향을 줄 수 있기 때문입니다. 따라서 하나의 단어가 들어올때 마다 번역해서 단어를 뽑아내는 것(RNN의 방식)이 아닌 전체 문장이 주입될 때까지 기다렸다 번역해야 더 좋은 성능을 낼 수 있습니다.(인코더-디코더 모델)


RNN 훈련하기

 RNN 훈련 방식으로 타임 스텝으로 네트워크를 펼치고, 보통의 역전파(Back Propagation)을 사용하는 것인데 이를 BPTT(Backpropagation through time)이라고 합니다. 본격적으로 훈련 방식을 차례대로 살펴보겠습니다.

  위 그림을 살펴보면서 이해해보겠습니다. 우선 회색 점선 화살표 방향으로 네트워크를 통과하면서 비용 함수 $C(Y_{(0)}, Y_{(1)} ... Y_{(T)})$(T는 최대 타임 스텝)을 사용하여 출력 시퀀스를 평가됩니다. 이때 비용 함수는 Y_{(0)}, Y_{(1)}과 같은 일부 출력을 무시할 수 있습니다.

 그 다음 비용함수의 Gradient는 짙은 회색선의 방향으로 전파되면서 모델 파라미터는 업데이트 됩니다. 이때 Gradient는 마지막 출력 $Y_{(4)}$에만 전달되어 노란색 박스 간의 수평방향으로 전달되는 것이 아니라, 비용 함수에 사용된 Y_{(2)}, Y_{(3)}, Y_{(4)}에 모두 각각 전달됩니다. 또한 각 타임 스텝마다 같은 매개변수 $W$와 $b$가 사용되기 때문에 역전파가 진행되면 모든 타임 스텝에 걸쳐 합산될 것입니다.

 

 

 

 

 


Time-Series Forecasting

 어떤 웹사이트에서 1) 시간당 접속 사용자의 수, 2) 도시의 날짜별 온도, 3) 여러 feature를 사용하여 기업의 분기별 재정 안정성 등을 연구한다고 가정해보겠습니다. 이 경우 들어오는 Input Data는 타임 스텝마다 하나 이상의 값을 가지는 시퀀스이며 이를 Time-Series Data라고 부릅니다.

 [1) 시간당 접속 사용자의 수, 2) 도시의 날짜별 온도]는 타임 스텝마다 하나의 feature를 가지므로 단변량 시계열 (univariate time series)이고, 3)기업의 분기별 재정 안정성은 회사의 수입, 부채 등의 여러 feature를 이용하므로 다변량 시계열(multiivariate time series)입니다. 

  이런 Time-Series Data를 가지고 할 수 있는 몇가지 Task가 있는데 첫번째가 미래 예측(Forecasting)입니다. 또 하나는 기존 데이터에서 비어 있는 값을 채우는 Imputation입니다.

 다음은 앞서 예시로 든 데이터와 별개의 3개의 단변량 시계열입니다. 각 타임 스텝은 50개이며, 목표는 51번째 타임 스텝의 값을 예측 하는 것입니다.

 다음 코드에서 간단하게 $sin$ 곡선과 약간의 잡음으로 이뤄진 시계열 데이터를 생성해보겠습니다. 

def generate_time_series(batch_size, n_steps):
    freq1, freq2, offsets1, offsets2 = np.random.rand(4, batch_size, 1)
    time = np.linspace(0, 1, n_steps)
    series = 0.5 * np.sin((time - offsets1) * (freq1 * 10 + 10))  #   wave 1
    series += 0.2 * np.sin((time - offsets2) * (freq2 * 20 + 20)) # + wave 2
    series += 0.1 * (np.random.rand(batch_size, n_steps) - 0.5)   # + noise
    return series[..., np.newaxis].astype(np.float32)

 이 함수는 n_steps 길이의 시계열을 batch_size만큼 만들어냅니다. 각 시계열의 타임 스텝에는 하나의 값이 존재 즉 단변량이며 [배치 크기, 타임 스텝 수, 1]크기의 넘파이 배열을 반환합니다. 이러한 리턴 타입은 일반적으로 임의 길이의 time-series 데이터를 다룰 때 사용하며, 단변량은 dimensionallity가 1이고, 다변량은 1이상입니다.

 이제 이 함수를 사용해 데이터를 train, validation, test set으로 나누겠습니다.

np.random.seed(42)

n_steps = 50
series = generate_time_series(10000, n_steps + 1)
X_train, y_train = series[:7000, :n_steps], series[:7000, -1]
X_valid, y_valid = series[7000:9000, :n_steps], series[7000:9000, -1]
X_test, y_test = series[9000:, :n_steps], series[9000:, -1]

기준 성능

 본격적으로 RNN을 구현하기 전, 우리가 구현할 RNN이 잘 작동하는지 판단하기 위해서는 기준이 되는 무언가가 필요합니다. 가장 간단한건 각 시계열의 마지막 값을 그대로 예측하는 것입니다. 이를 naive forecasting이라고 부르는데 이 성능을 뛰어넘는 것이 매우 어렵스빈다. 이 경우 MSE가 0.020이 나오네요

y_pred = X_valid[:, -1]
np.mean(keras.losses.mean_squared_error(y_valid, y_pred))

>> 0.020211367

 또 다른 방법은 Fully Connected Network를 사용하는 것입니다. 이 네트워크는 입력마다 1차원 feature 배열을 기대하므로 Flatten Layer를 추가해줘야 합니다. 

model = keras.models.Sequential([
    keras.layers.Flatten(input_shape=[50, 1]),
    keras.layers.Dense(1)
])

model.compile(loss="mse", optimizer="adam")
history = model.fit(X_train, y_train, epochs=20,
                    validation_data=(X_valid, y_valid))
                    
model.evaluate(X_valid, y_valid)

>>0.004168086685240269

 0.004의 MSE의 값을 얻었는데 naive한 forecasting보다 훨씬 낫습니다.

 

 

 

 


간단한 RNN 구현하기

이제 간단한 RNN을 사용해 방금 구현한 네트워크들을 앞설 수 있는지 확인해보겠습니다.

model = keras.models.Sequential([
    keras.layers.SimpleRNN(1, input_shape=[None, 1])
])

optimizer = keras.optimizers.Adam(lr=0.005)
model.compile(loss="mse", optimizer=optimizer)
history = model.fit(X_train, y_train, epochs=20,
                    validation_data=(X_valid, y_valid))

 위 코드가 가장 간단하게 만들 수 있는 RNN으로 하나의 뉴런을 가지는 하나의 layer로 이루어져 있습니다. 코드에서 input_shapeNone이 들어있는 이유는 RNN은 어떤 길이의 타임 스텝도 처리할 수 있기 때문에 길이를 지정해 줄 필요가 없습니다.

 기본적으로 SimepleRNN Layers는 tanh를 activation function으로 사용합니다. 작동방식은 앞서 이론에서 살펴보았던 것과 완전히 동일합니다.

model.evaluate(X_valid, y_valid)
>> 0.010881561785936356

 우리가 구현한 RNN을 통해 얻은 0.014의 MSE는 naive forecasting보단 낫지만, 간단한 FC Layer 기반 선형 모델을 앞지르지 못합니다. 이는 선형 모델에서는 전체 타임스텝이 50개라 했을 때, 50개의 뉴런이 준비되어 각 입력을 받고 하나의 입력마다 하나의 파라미터를 가지고 편향까지 존재합니다. 최종적으로 51개의 파라미터가 있는 셈이죠.

 반면 기본 RNN의 순환 뉴런은 Input과 셀 h의 차원마다 하나의 파라미터를 가지고 편향이 있습니다. 그러니 3개의 파라미터가 있는 셈인데, 선형 모델보다 파라미터 갯수가 월등히 적기 때문에 성능도 그만큼 덜 나오는 것입니다.


트렌드와 계절성

 가중 이동 평균(weighted moving average)이나 자동 회귀 누적 이동 평균(ARIMA)같이 Time-Series Data를 forecasting하는 방법은 많습니다. 그런데 올바른 forecasting을 위해서 일부는 트렌드(Trend)계절성(Seasonality)를 제거해야 합니다. 트렌드라 하면 전체적인 데이터가 상승하거나 내려가거나 하는 추세를 의미합니다. 예를 들어 매달 10% 성장하는 추세의 웹사이트의 접속 사용자 수를 조사한다면, 시계열에서 트렌드를 삭제하고 예측한 후 최종 결과에 다시 이 트렌드를 더해야합니다. 

 비슷하게 매달 선크림 판매량 예측 시 여름에 특히 더 잘 팔리는 계절성을 관찰할 수 있습니다. 따라서 마찬가지로 최종 예측 후 이러한 계절성을 더해줘야합니다.

 하지만 RNN을 사용하면 이런 작업이 필요 없습니다. 그만큼 RNN은 좋은 성능을 내기에 단순합니다. 더 많은 Layer를 추가하면 됩니다!


심층 RNN

RNN은 셀을 여러 층으로 쌓는 것이 일반적입니다. 이렇게 만든 것을 심층 RNN (Deep RNN)이라 부릅니다.

 구현은 그저 SimpleRNN을 쌓아주면 됩니다.

model = keras.models.Sequential([
    keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
    keras.layers.SimpleRNN(20, return_sequences=True),
    keras.layers.SimpleRNN(1)
])

 이 모델의 MSE는 0.003으로 드디어 선형 모델을 이기게 되었습니다! 그러나 마지막 층을 좀 더 손볼 필요가 있어 보입니다. 단변량 시계열을 예측한다는 건 하나의 유닛이 필요하고 이는 타임 스텝마다 하나의 출력을 만들어야 한다는 뜻입니다. 하나의 유닛을 가진다는 건 h가 스칼라 값이라는 것입니다.

 그런데 마지막 Layer의 h는 그리 필요하지 않습니다. 게다가 SimpleRNN Layer는 tanh 함수를 사용하여 forecasting 값이 -1과 1 사이의 범위에 놓입니다. 따라서 보통은 이런 이유로 출력층을 Dense 층으로 바꾸는데, 이를 통해 빠르면서 정확도는 거의 비슷합니다. 다만 이렇게 바꾸려면 두 번째 순환 층에 return_sequences=True를 제거해줘야합니다.

model = keras.models.Sequential([
    keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
    keras.layers.SimpleRNN(20),
    keras.layers.Dense(10)
])

 

 

 

 


여러 타임 스텝 앞을 예측하기

 지금까지는 다음 타임 스텝의 값만 예측했지만, 1스텝 앞이 아니라, 10스텝 앞의 값으로 타깃을 바꾸어 10 스텝 앞을 예측하는 것도 그리 어려운 일이 아닙니다. 하지만 1개의 값이 아니라 한번에 10개를 예측하고 싶다면 어떻게 해야할까요?

 첫 번째 방법은 pre-trained model을 사용하여 다음 스텝의 값을 예측한 후 이 값을 다시 입력으로 추가해나가며 10개를 예측하는 것입니다. 코드는 다음과 같습니다.

series = generate_time_series(1, n_steps + 10)
X_new, Y_new = series[:, :n_steps], series[:, n_steps:]
X = X_new
for step_ahead in range(10):
    y_pred_one = model.predict(X[:, step_ahead:])[:, np.newaxis, :]
    X = np.concatenate([X, y_pred_one], axis=1)

Y_pred = X[:, n_steps:]

 당연하지만 다음 스텝에 대한 예측이 훨씬 더 미래의 예측보다 정확합니다. 훨씬 더 미래의 예측은 오차가 누적될 수 있기 때문입니다. 어쨌든 위 방식을 적용하면 약 0.029의 MSE를 얻습니다. 성능은 비슷할지라도 task가 훨씬 어렵기 때문에 단순 비교는 어렵습니다.

 두 번째 방법은 RNN을 훈련하여 다음 값 10개를 한번에 예측하는 것입니다. 시퀀스-투-벡터 모델을 사용하지만, 1개가 아닌  값 10개를 출력합니다.

series = generate_time_series(10000, n_steps + 10)
X_train, Y_train = series[:7000, :n_steps], series[:7000, -10:, 0]
X_valid, Y_valid = series[7000:9000, :n_steps], series[7000:9000, -10:, 0]
X_test, Y_test = series[9000:, :n_steps], series[9000:, -10:, 0]

model = keras.models.Sequential([
    keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
    keras.layers.SimpleRNN(20),
    keras.layers.Dense(10)
])

 이 모델의 다음 10개 스텝에 대한 MSE는 약 0.008로 선형 모델보다 훨씬 좋습니다. 다만 개선할 여지가 있는데, 마지막 타임스텝에서만 10개를 예측하도록 훈련하는게 아니라, 모든 타임 스텝에서 다음 10개를 예측하도록 모델을 훈련할 수 있습니다. 즉 시퀀스-투-벡터에서 시퀀스-투-시퀀스로 바꿀 수 있는 것입니다.(타임 스텝 0에서는 타임 스텝 1~10까지 예측을 담은 벡터를, 타임 스텝 1에서는 2~11까지의 예측을 담는 벡터를 출력합니다.)

 이것의 장점은 모든 타임 스텝에서 forecasting을 진행하며 이것들에 대한 출력이 loss에 포함되면서 더 많은 오차 그레디언트가 모델로 흐르게 되고 결과적으로 안정적인 훈련과 함께 훈련 속도를 높이게 됩니다. 다음은 구현 코드입니다.

model = keras.models.Sequential([
    keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
    keras.layers.SimpleRNN(20, return_sequences=True),
    keras.layers.TimeDistributed(keras.layers.Dense(10))
])

 시퀀스-투-시퀀스 모델로 바꾸려면 우선 모든 SimpleRNN 층의 return_sequences=True로 지정합니다. 그 다음 모든 타임 스텝 층에 Dense 층을 적용해야하는데 이를 위해 바로 TimeDistributed Layer를 사용합니다.

TimeDistributed Layer는 각 타임 스텝을 별개의 샘플처럼 다루도록 입력의 크기를 바꿉니다. ([배치 크기, 타임 스텝 수, 입력 차원] -> [배치 크기 x 타임 스텝 수, 입력 차원]) 그 다음 Dense Layer에 적용하고 마지막으로 출력 크기를 시퀀스로 되돌립니다. ([배치 크기 x 타임 스텝 수, 입력 차원] -> [배치 크기, 타임 스텝 수, 입력 차원])

def last_time_step_mse(Y_true, Y_pred):
    return keras.metrics.mean_squared_error(Y_true[:, -1], Y_pred[:, -1])

model.compile(loss="mse", optimizer=keras.optimizers.Adam(lr=0.01), metrics=[last_time_step_mse])
history = model.fit(X_train, Y_train, epochs=20,
                    validation_data=(X_valid, Y_valid))

 검증 MSE로 0.006을 얻었는데, 이는 이전 모델보다 25%나 향상된 것입니다. 

 

 

 

 


다음 시간에는 좀 더 긴 시퀀스의 예측 시 사용하는 LSTM과 경량화된 모델인 GRU에 대해 공부해보겠습니다. 오늘도 읽어주셔서 감사합니다. 행복한 하루 보내시길 바랍니다 :)

반응형