Study/MachineLearning

[Deep Learning with Python] 4-3. 순환 신경망의 고급 사용법

soohwan_justin 2021. 9. 12. 11:55

이번 포스트는 케라스 창시자에게 배우는 딥러닝(Deep Learning with Python)의 ch.6을 참고하였습니다.

 

이번 포스트를 읽기 전에, [Do it!] 시리즈를 먼저 읽는것을 권장합니다. 이 포스트에서는 [Do it!] 시리즈에 중복해서 나온 내용들을 많이 생략하였습니다.

 

이 책은 딥 러닝의 입문서로서는 적절하지 않다고 생각됩니다. 딥 러닝에 대한 어느정도 지식이 있고, 케라스의 사용방법에 대해 좀 더 자세히 알고 싶은 분께 권장합니다.

 

 

 

 

 

 

기온 예측 문제

 

이번에는 시퀀스 데이터 중 하나인 온도 예측 문제에 RNN을 적용해볼 것입니다. 데이터를 사용하여 마지막 데이터 포인로부터 24시간 후의 온도를 예측해볼 것입니다. 이를 위해 다음과 같은 기법들을 사용해볼 것입니다.

 

- 순환 드롭아웃 : 순환 층에서 과대적합을 방지하기 위해 드롭아웃을 사용합니다.

- 스태킹 순환 층 : 더 많은 층을 쌓아서 네트워크의 표현 능력을 증가시킵니다.

- 양방향 순환 층 : 순환 네트워크에 같은 정보를 다른 방향으로 주입하여 정확도를 높이고, 기억을 좀 더 오래 유지시킵니다.

 

 

이번에 사용할 데이터는 수년간에 걸쳐 기온, 기압, 습도 풍향 등의 14개의 관측치가 10분마다 기록되어있습니다. 링크에서 데이터를 다운받고, 압축을 해제합니다.

 

먼저, 다음과 같이 데이터를 확인해봅니다.

import os
 
data_dir = './datasets/jena_climate'
fname = os.path.join(data_dir, 'jena_climate_2009_2016.csv')
 
f = open(fname)
data = f.read()
f.close
 
lines = data.split('\n')
header = lines[0].split(',')
lines = lines[1:]
 
print(header)
print(len(lines))

 

데이터 수는 420451개입니다. 이 파일을 열어보면 다음과 같습니다.

 

이제 이 데이터 전체를 넘파이 배열로 바꿉니다.

 

import numpy as np
 
float_data = np.zeros((len(lines), len(header) - 1))
for i, line in enumerate(lines):
    values = [float(x) for x in line.split(',')[1:]] # 날짜는 제외합니다
    float_data[i, :] = values

 

 

10분마다 데이터가 기록되므로 하루에는 144개의 데이터 포인트가 있습니다. 첫 10일의 데이터 중, 기온에 대한 데이터를 확인해봅니다.

%matplotlib inline
import matplotlib.pyplot as plt
 
temp = float_data[:, 1]
plt.plot(range(1440), temp[:1440])

 

연간 데이터의 주기성은 안정적이기 때문에, 지난 몇 달의 데이터를 사용하여 다음 달의 평균 온도를 예측하는 문제는 쉽습니다. 하지만 하루하루의 데이터를 보면, 온도 변화는 매우 불안정합니다. 일별 수준의 시계열 데이터를 예측할 수 있을지 한번 확인해보겠습니다.

 

 

데이터 준비

 

먼저, 다음과 같은 변수를 사용합니다

 

- lookback = 1440 : 10일 전의 데이터로 돌아갑니다

- steps = 6 : 60분마다 데이터 포인트 하나를 샘플링합니다

- delay = 144 : 1440분(24시간)이 지난 데이터가 타깃이 되며, 이를 예측하려고 합니다.

 

즉, 이번에 해결할 문제의 정의는 다음과 같습니다. lookback 타임스텝(하나의 타임스텝은 10분입니다)만큼 이전으로 돌아가서 매 steps 타임스텝마다 데이터를 샘플링하여, delay 타임스텝 후의 온도를 예측하는 것입니다. 시작하기 전에, 먼저 두 가지 작업을 처리해야합니다.

 

- 데이터의 각 특성의 범위가 서로 다르므로, 정규화가 필요합니다.

- float_data 배열을 받아 과거 데이터의 배치와 미래의 타깃 온도를 추출하는 파이썬 제너레이터를 만듭니다. 모든 샘플을 각기 메모리에 저장하는 것은 낭비가 심하므로 원본 데이터를 사용하여 필요할 때마다 배치를 만듭니다.

 

 

먼저, 데이터를 전처리합니다. 이번에는 약 40만개의 데이터 중 20만개의 데이터만을 사용할 것입니다.

mean = float_data[:200000].mean(axis=0)
float_data -= mean
std = float_data[:20000].std(axis=0)
float_data /= std

 

다음으로, (samples, targets) 튜플을 리턴할 제너레이터 함수를 만듭니다. samples는 입력으로 사용할 배치이고, targets는 이에 대응되는 타깃 온도의 배열입니다. 이 함수에서 사용하는 매개변수들은 다음과 같습니다.

 

- data : 원본 배열

- lookback : 입력으로 사용하기 위해 거슬러 올라갈 타임스텝

- delay : 타깃으로 사용할 미래의 타임스텝

- min_index, max_index : 추출할 타임스텝의 범위를 지정하기 위한 인덱스. 검증 데이터를 분리하기위해 사용합니다.

- shuffle : 샘플을 섞을지, 시간 순서대로 추출할지를 결정

- batch_size : 배치의 샘플 수

-step : 데이터를 샘플링할 타임스텝 간격

def generator(data, lookback, delay, min_index, max_index,
              shuffle=False, batch_size=128, step=6):
    if max_index is None:
        max_index = len(data) - delay - 1
    i = min_index + lookback
 
    while 1:
        if shuffle:
            rows = np.random.randint(min_index+lookback,
                                     max_index, size=batch_size)
        else:
            if i + batch_size >= max_index:
                i = min_index + lookback
            rows = np.arange(i, min(i + batch_size, max_index))
            i += len(rows)
 
        samples = np.zeros((len(rows),
                            lookback // step,
                            data.shape[-1]))
        targets = np.zeros((len(rows),))
        for j, row in enumerate(rows):
            indices = range(rows[j] - lookback, rows[j], step)
            samples[j] = data[indices]
            targets[j] = data[rows[j] + delay][1]
        yield samples, targets

 

위 함수를 사용하여 훈련, 검증, 테스트 데이터를 만들어봅니다.

lookback = 1440
step = 6
delay = 144
batch_size = 128
 
train_gen = generator(float_data, lookback=lookback,
                      delay=delay, min_index=0, max_index=200000,
                      shuffle=True, step=step, batch_size=batch_size)
 
val_gen = generator(float_data, lookback=lookback,
                    delay=delay, min_index=200001, max_index=300000,
                    shuffle=True, step=step, batch_size=batch_size)
 
test_gen = generator(float_data, lookback=lookback,
                     delay=delay, min_index=300001, max_index=None,
                     shuffle=True, step=step, batch_size=batch_size)
 
val_steps = (300000 - 200001 - lookback) // batch_size
test_steps = (len(float_data) - 300001 - lookback) // batch_size

 

상식 수준의 기준점

 

상식 수준의 기준점이란, 정상 여부 확인을 위한 용도로, 고수준 머신 러닝 모델이 뛰어넘어야 할 기준점을 의미합니다. 이런 상식 수준의 해법은 알려진 해결책이 없는 새로운 문제를 다뤄야할 때 유용합니다.

 

예를 들어, 어떤 데이터셋에 클래스 A의 샘플이 70%, B의 샘플이 30%가 있다면, 이 문제에 대한 상식 수준의 접근법은 항상 샘플을 A라고 예측하는 것이고, 이 정확도는 약 70%가 나올 것입니다. 머신 러닝 기반 모델이라면 최소한 70%은 넘겨야 유용하다고 볼 수 있습니다.

 

이번의 경우, 온도 시계열 데이터는 연속성이 있고, 일자별로 주기성을 가진다고 할 수 있으므로 상식 수준의 기준점은 지금으로부터 24시간 후의 온도는 지금과 동일하다고 예측하는 것입니다. 이 방법을 평균 절댓값 오차(MAE)로 평가해보겠습니다.

def evaluate_naive_method():
    batch_maes = []
    for step in range(val_steps):
        samples, targets = next(val_gen)
        preds = samples[:, -1, 1]
        mae = np.mean(np.abs(preds - targets))
        batch_maes.append(mae)
    print(np.mean(batch_maes))
 
evaluate_naive_method()

 

위 결과는 평균이 0이고, 표준편차가 1인 데이터의 결과입니다. 이 값에 표준 편차를 곱해보겠습니다.

celsius_mae = 0.334 * std[1]
celsius_mae

 

24시간 후의 온도가 지금과 같다고 예측했을 때의 평균 오차는 2.55°C입니다. 

 

 

기본적인 머신 러닝 방법

 

머신 러닝 모델을 시도하기 전에 상식 수준의 기준점을 만들었습니다. 이와 비슷하게, RNN처럼 복잡하고 연산 비용이 많이 드는 모델을 시도하기 전에, 간단하고 쉽게 만들어서 상식 수준의 기준점같이 비교해볼 수 있는 머신 러닝 모델을 먼저 만드는 것이 좋습니다.

 

다음 모델은 데이터를 펼쳐 2개의 Dense층을 통과시키는 완전 연결 네트워크입니다. 상식 수준의 방법에서 사용한 것과 동일한 데이터와 지표를 사용했으므로, 결과를 바로 확인할 수 있습니다.

from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop
 
model = Sequential()
model.add(layers.Flatten(input_shape=(lookback // step, float_data.shape[-1])))
model.add(layers.Dense(32, activation='relu'))
model.add(layers.Dense(1))
model.compile(optimizer=RMSprop(), loss='mae')
history = model.fit_generator(train_gen,
                              steps_per_epoch=500,
                              epochs=20,
                              validation_data=val_gen,
                              validation_steps=val_steps)

 

 

훈련 결과는 다음과 같습니다.

loss = history.history['loss']
val_loss = history.history['val_loss']
 
epochs = range(1, len(loss) + 1)
 
plt.figure()
 
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
 
plt.show()

 

앞서 만들었던 상식적인 기준 모델은 약 0.335정도의 오차를 보였는데, 위의 네트워크 훈련 결과는 딱히 기준 모델의 성능보다 뛰어나다고 보기는 어려워보입니다.

 

 

 

첫 번째 순환 신경망

 

첫 번째 완전 연결 네트워크는 시계열 데이터를 펼쳤기 때문에 입력 데이터에서 시간 개념을 잃어버렸습니다. 이번에는 그 대신 인과 관계와 순서가 의미 있는 시퀀스 데이터를 사용해볼 것이며, 이런 시퀀스 데이터에 잘 맞는  순환 시퀀스 처리 모델을 시도해볼 것입니다.

 

이번에는 LSTM 대신 GRU(Gated Recurent Unit)층을 사용할 것입니다. GRU은 LSTM과 같은 원리로 작동하지만 좀 더 간결하고, 이에 따라 계산 비용이 조금 덜 드는 대신, LSTM만큼 표현 학습 능력이 높지는 않을 수 있습니다.

from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop
 
model = Sequential()
model.add(layers.GRU(32, input_shape=(None, float_data.shape[-1])))
model.add(layers.Dense(1))
 
model.compile(optimizer=RMSprop(), loss='mae')
history = model.fit_generator(train_gen, steps_per_epoch=500,
                              epochs=20, validation_data=val_gen,
                              validation_steps=val_steps)

 

새로운 검증 MAE는 약 0.26정도이고, 이 값을 섭씨로 복원하면 1.99°C로, 초기 에러보다는 더 나은 모습을 보여줍니다.

 

 

 

과대적합을 줄이기 위해 드롭아웃 사용하기

 

위 그래프를 보니 5~6번째 에포크부터 과대적합이 시작되는 것 같습니다. 과대적합을 줄이는 방법 중에는 드롭아웃이 있는데, 순환 신경망에서 드롭아웃을 사용하는 방법은 완전 연결 신경망에 드롭아웃을 추가하는 것 처럼 간단하지 않습니다. 순환 신경망 층 이전에 드롭아웃을 적용하면, 규제에 도움이 되기 보다는 학습에 더 방해되는것으로 알려졌습니다.

 

하지만 2015년, 야린 갈(Yarin Gal)이 베이지안 딥러닝에 관한 논문에서 순환 네트워크에 적절하게 드롭아웃을 사용하는 방법을 알아냈습니다. 이는 타임스텝마다 랜덤하게 드롭아웃을 바꾸는 것이 아니라, 동일한 드롭아웃을 모둔 타임스텝에 적용하는 것입니다. GRU나 LSTM같은 순환 게이트에 의해 만들어지는 표현을 규제하기 위해서는 타임스텝마다 동일한 드롭아웃을 순환 층 내부 계산에 사용된 활성화 함수에 적용해야 합니다.

from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop
 
model = Sequential()
model.add(layers.GRU(32,
                     dropout=0.2,
                     recurrent_dropout=0.2,
                     input_shape=(None, float_data.shape[-1])))
model.add(layers.Dense(1))
 
model.compile(optimizer=RMSprop(), loss='mae')
history = model.fit_generator(train_gen,
                              steps_per_epoch=500,
                              epochs=40,
                              validation_data=val_gen,
                              validation_steps=val_steps)

 

과대적합은 줄어들었고, 검증 점수는 더 안정적이긴 하지만, 점수 자체가 더 좋아지진 않았습니다.

 

 

 

스태킹 순환 층

 

이제 과대적합은 더이상 없지만, 성능 상 병목이 있는 것 같으므로 네트워크의 용량을 늘려야 합니다. 일반적인 머신 러닝 작업은 드롭아웃 등 과대적합을 줄이는 기본 단계를 거치고 나서, 다시 과대적합이 일어날 때까지 네트워크의 용량을 늘리는 것이 좋습니다. 너무 많이 과대적합되지 않는 한, 아직 충분한 용량에 도달한 것이 아닙니다.

 

네트워크의 용량을 늘리기 위해서는 일반적으로 유닛의 수를 늘리거나 층을 더 추가합니다.

 

케라스에서 순환 층을 제대로 쌓으려면 중간 층에서 전체 시퀀스를 출력해야 합니다.

from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop
 
model = Sequential()
model.add(layers.GRU(32,
                     dropout=0.1,
                     recurrent_dropout=0.5,
                     return_sequences=True,
                     input_shape=(None, float_data.shape[-1])))
model.add(layers.GRU(64, activation='relu',
                     dropout=0.1, 
                     recurrent_dropout=0.5))
model.add(layers.Dense(1))
 
model.compile(optimizer=RMSprop(), loss='mae')
history = model.fit_generator(train_gen,
                              steps_per_epoch=500,
                              epochs=40,
                              validation_data=val_gen,
                              validation_steps=val_steps)

 

층을 추가시켰지만, 성능에는 크게 변화가 없습니다. 따라서, 다음과 같은 결론을 얻을 수 있습니다.

 

- 아직 충분히 과대적합을 만들지 못했기 때문에 층을 더 늘릴 수 있지만, 계산 비용이 많이 추가됩니다.

- 층을 늘렸지만 별 도움이 되지 않았으므로, 단순히 네트워크 용량을 늘리는 것은 성능 개선에 도움이 되지 않습니다.

 

 

 

양방향 RNN

 

양방향 RNN(bidirectional RNN)은 RNN의 한 변종이고, 특정 작업에서는 RNN보다 훨씬 좋은 성능을 냅니다. RNN은 순서 또는 시간에 특히 더 민감합니다. 즉, 입력 시퀀스의 타임스텝 순서대로 처리하는 것입니다. 양방향 RNN은 입력 시퀀스를 반대방향으로 처리한 후, 원래 방향으로 처리한 결과와 합칩니다. 시퀀스를 양방향으로 처리하기 때문에 단방향 RNN이 놓치기 쉬운 패턴을 감지할 수 있습니다.

 

양방향 RNN을 구현하기 위해 해야할 일은 시간의 차원을 따라 역방향으로 생성하는 데이터 제너레이터를 만드는 것입니다.

def reverse_order_generator(data, lookback, delay, min_index, max_index,
                            shuffle=False, batch_size=128, step=6):
    if max_index is None:
        max_index = len(data) - delay - 1
    i = min_index + lookback
    while 1:
        if shuffle:
            rows = np.random.randint(
                min_index + lookback, max_index, size=batch_size)
        else:
            if i + batch_size >= max_index:
                i = min_index + lookback
            rows = np.arange(i, min(i + batch_size, max_index))
            i += len(rows)
 
        samples = np.zeros((len(rows),
                           lookback // step,
                           data.shape[-1]))
        targets = np.zeros((len(rows),))
        for j, row in enumerate(rows):
            indices = range(rows[j] - lookback, rows[j], step)
            samples[j] = data[indices]
            targets[j] = data[rows[j] + delay][1]
        yield samples[:, ::-1, :], targets
 
train_gen_reverse = reverse_order_generator(
    float_data,
    lookback=lookback,
    delay=delay,
    min_index=0,
    max_index=200000,
    shuffle=True,
    step=step, 
    batch_size=batch_size)
val_gen_reverse = reverse_order_generator(
    float_data,
    lookback=lookback,
    delay=delay,
    min_index=200001,
    max_index=300000,
    step=step,
    batch_size=batch_size)

하지만, 양방향 RNN으로 만든 모델을 온도 예측에 사용하면 별로 좋은 결과를 보지 못합니다. 날씨 데이터의 경우, 최근에 가까운 날씨 데이터일수록 오래된 데이터보다 예측에 유용하기 때문입니다. 하지만 자연어 처리 등 많은 문제에서는 그렇지 않습니다. 어떤 문장을 이해하는데 있어 단어의 위치는 그 단어의 중요성과 별로 관계가 없습니다. 이번에는 이전에 구현해봤던 LSTM을  데이터를 반대로 뒤집어서 훈련해보겠습니다.

 

왼 쪽은 기존의 LSTM이고, 오른쪽은 입력을 반대로 넣은 LSTM입니다. 시간 순서대로 훈련한 LSTM과 거의 같은 성능을 보여줍니다. 이 결과는 언어를 이해하는 데 단어의 순서가 중요하긴 하지만, 결정적이지는 않다는 가정을 뒷받침합니다.

 

이렇게 거꾸로 된 시퀀스에서 훈련한 RNN은 기존의 시퀀스에서 훈련한 것과는 다른 표현을 학습합니다. 이렇게 새로운 표현은 기존의 표현에서는 놓칠 수 있는 데이터의 특징을 잡아냅니다. 양방향 RNN은 이 아이디어를 사용하여 RNN의 성능을 향상시킵니다. 입력 시퀀스를 양방향으로 바라보기 때문에, 다양한 표현을 얻어 기존의 방법만으로 처리할 때 놓칠 수 있는 패턴을 잡아냅니다.

 

케라스에서는 Bidirectional 층을 사용하여 양방향 RNN을 만듭니다. 이 클래스는 첫 번째 매개변수로 순환 층의 객체를 전달받으며, 전달받은 순환 층으로 두 번째 객체를 만듭니다. 하나는 시간 순서대로고, 하나는 시간의 역순으로 시퀀스를 처리합니다.

 

model = Sequential()
model.add(layers.Embedding(max_features, 32))
model.add(layers.Bidirectional(layers.LSTM(32)))
model.add(layers.Dense(1, activation='sigmoid'))
 
model.compile(optimizer='rmsprop', loss='binary_crossentropy',
              metrics=['acc'])
 
history = model.fit(x_train, y_train,
                    epochs=10, batch_size=128,
                    validation_split=0.2)

 

검증 정확도는 약 88%로, 일반 LSTM보다 성능이 조금 더 높습니다. 과대 적합이 일찍 시작되긴 하지만, 규제를 조금 추가하면 더 안정적인 출력을 보일 수 있을 것 같습니다.