안 쓰던 블로그

이진 분류: 케라스로 영화 리뷰 분류하기 본문

머신러닝/머신러닝

이진 분류: 케라스로 영화 리뷰 분류하기

proqk 2021. 4. 4. 22:01
반응형

인터넷 영화 데이터베이스로부터 생성된 양극단의 리뷰 50,000개로 이루어진 IMDB 데이터셋을 사용하여 이진 분류를 실습해 보겠다.

이 데이터셋은 훈련 데이터 25,000개와 테스트 데이터 25,000개로 나뉘어 있고 각각 50%는 부정, 50%는 긍정 리뷰로 구성되어 있다.

MNIST 데이터셋처럼 IMDB 데이터셋도 케라스에 포함되어 있다. 또한 전처리되어 있어 각 리뷰(단어 시퀀스)가 숫자 시퀀스로 되어 있다. 각 숫자는 사전에 있는 고유한 단어를 나타낸다.

 

케라스 버전 2.4.3와 코랩을 이용하여 실습하였고, 케라스 창시자에게 배우는 딥러닝 책을 참고하였다.

 

데이터셋 준비

from keras.datasets import imdb

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(num_words=10000)

먼저 데이터셋을 로드한다. num_words=10000 매개변수를 주어서 훈련 데이터에서 가장 자주 나타나는 단어 1만 개를 사용한다. 드물게 나타나는 단어를 무시하여 적절한 크기의 벡터 데이터를 얻을 수 있다.

train_data와 train_data는 리뷰의 목록이며, 각 리뷰는 인코딩된 단어 인덱스의 리스트이다.

train_labels와 test_labels는 부정은 0, 긍정은 1로 나타내는 리스트이다.

가장 자주 등장하는 단어 10,000개로 제한했기 때문에 단어 인덱스는 10,000을 넘지 않는다. 

 

리뷰 데이터 형태는 [1, 14 ,5 ...] 인데, 아래의 방법으로 디코딩하여 원래 영어 단어로 바꿀 수 있다.

word_index = imdb.get_word_index()
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])
decoded_review = ' '.join([reverse_word_index.get(i - 3, '?') for i in train_data[0]])

word_index는 단어와 정수 인덱스를 매핑한 딕셔너리이다.

정수 인덱스와 단어를 매핑하도록 뒤집는다.

인덱스 0, 1, 2는 각각 패딩, 문서의 시작, 사전에 없음 의미를 위한 인덱스이므로 3을 빼고 join한다

결과는 다음과 같다. train_data[0]의 첫 번째 원소가 문서 시작을 알리는 인덱스 1인데, 3을 뺀 값은 딕셔너리에 없으므로 ?로 나타났다.

 

데이터 준비

리스트를 텐서로 바꾸어야 한다. 리스트를 텐서로 바꾸는 데는 두 가지 방법이 있다.

 

1. 같은 길이가 되어야 하므로 리스트에 패딩을 추가하여 (samples, sequence_length) 크기의 정수 텐서로 변환한다. 현재 있는 리뷰 중 가장 긴 리뷰는 2494길이를 가졌기 때문에 훈련 데이터를 변환한 텐서의 크기는 (25000, 2494)가 된다.

2. 리스트를 원-핫 인코딩하여 0과 1의 벡터로 변환한다. 예를 들면 시퀀스 [3, 5]를 인덱스 3과 5의 위치는 1이고 그 외는 모두 0인 10,000차원의 벡터로 각각 변환한다. 이 경우 리스트가 하나의 벡터로 변환되므로 훈련 데이터를 변환한 텐서의 크기는 (25000, 10000)이 된다. 그다음 부동 소수 벡터 데이터를 다룰 수 있는 Dense 층을 신경망의 첫 번째 층으로 사용한다.

 

여기서는 두 번째 방법을 사영하여 데이터를 원-핫 벡터로 만든다.

import numpy as np

def vectorize_sequences(sequences, dimension=10000):
    # 크기가 (len(sequences), dimension))이고 모든 원소가 0인 행렬
    results = np.zeros((len(sequences), dimension))
    for i, sequence in enumerate(sequences):
        results[i, sequence] = 1.  # results[i]에서 특정 인덱스의 위치를 1로
    return results

x_train = vectorize_sequences(train_data)
x_test = vectorize_sequences(test_data)

아까 [1, 14, 6..] 형태였던 정수 시퀀스가 array([0., 1., 1., ...]) 형태의 이진 행렬로 인코딩 된다.

레이블을 asarray로 벡터로 바꾸면 신경망에 주입할 데이터가 준비되었다.

 

신경망 모델 만들기

입력 데이터가 벡터이고 레이블은 스칼라(1 또는 0)이다. 이런 간단한 문제에 잘 작동하는 네트워크 종류는 relu 활성화 함수를 사용한 완전 연결 층(즉, Dense(16, activation='relu'))을 그냥 쌓은 종류이다.

Dense 층에 전달한 매개변수(16)는 은닉 유닛의 개수이다. 하나의 은닉 유닛은 층이 나타내는 표현 공간에서 하나의 차원이 된다. relu 활성화 함수를 사용한 Dense 층을 다음과 같은 식을 사용한다.

$$output = relu(dot(W, input) + b)$$

16개의 은닉 유닛이 있다는 것은 가중치 행렬 W의 크기가 (input_dimension, 16)이라는 뜻이다. 입력 데이터와 W를 점곱(=스칼라곱=내적)하면 입력 데이터가 16차원으로 표현된 공간으로 투영된다. 그 후 편향 벡터 b를 더하고 relu 연산을 적용한다.

 

Dense 층을 쌓을 때는 두 가진 중요한 구조상의 결정이 필요하다.

  • 얼마나 많은 층을 사용할 것인가
  • 각 층에 얼마나 많은 은닉 유닛을 둘 것인가

여기서는 다음과 같은 구조로 진행한다.

  • 16개의 은닉 유닛을 가진 두 개의 은닉층
  • 현재 리뷰의 감정을 스칼라 값의 예측으로 출력하는 세 번째 층

중간에 있는 은닉층은 활성화 함수로 relu를 사용하고 마지막 층은 확률(0과 1 사이의 점수로, 어떤 샘플의 값이 1일 가능성이 높다는 것은 리뷰가 긍정일 가능성이 높다는 의미이다)을 출력하기 위해 시그모이드 활성화 함수를 사용한다. 시그모이드는 임의의 값을 [0, 1] 사이로 압축하므로 출력 값을 확률처럼 해석할 수 있다.

 

relu는 음수를 0으로 만드는 함수이다. relu와 같은 활성화 함수(또는 비선형성 함수)가 없다면 Denso 층은 선형적인 연산인 점곱과 덧셈 2개로 구성된다.

$output = dot(W,input) + b$

그러므로 이 층은 입력에 대한 선형 변환만을 학습할 수 있다. 선형 층은 깊게 쌓아도 여전히 하나의 선형 연산이기 때문에 층을 여러 개로 구성하는 장점이 없다. 그렇기 때문에 가설 공간을 풍부하게 만들어 층을 깊게 하는 장점을 살리기 위해서는 활성화 함수가 있어야 한다.

 

신경망은 다음과 같은 모습이다.

이를 케라스로 구현하면 다음과 같다.

from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(16, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

 

이제 손실 함수와 옵티마이저를 선택해야 한다. 이진 분류 문제이고 신경망의 출력이 확률이기 때문에 binary_crossentropy 손실이 적합하다. 물론 회귀 문제에 사용되는 대표적인 손실 함수인 mean_squared_error나 mean_absolute_error처럼 여러 다른 함수도 있다. 확률을 출력하는 모델을 사용할 때는 크로스엔트로피crossentropy가최선의 선택이다.

다음과 같이 모델을 컴파일한다. accuray를 acc로 줄여서 사용할 수 있다.

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['accuracy'])

케라스에 rmsprop같은 문자열이 포함되어 있기 때문에 문자열로 지정이 가능하다. 커스텀으로 전달해야 할 경우에는 optimizers.~ 파이썬 클래스로 객체를 직접 만들어 주면 된다. loss와 metrics 매개변수도 또한 losses.~ metrics~로 함수 객체를 직접 전달할 수 있다. 아니면 y_true, y_pred 텐서를 입력으로 받는 함수를 직접 만들어 전달할 수도 있다. 케라스의 백엔드 함수를 사용하여 자신만의 손실 함수를 만들 수 있다.

 

훈련 검증

훈련하는 동안 처음 본 데이터에 대한 모델의 정확도를 측정하기 위해서는 원본 훈련 데이터에서 10,000의 샘플을 떼어서 검증 세트를 만들어야 한다.

x_val = x_train[:10000]
partial_x_train = x_train[10000:]

y_val = y_train[:10000]
partial_y_train = y_train[10000:]
history = model.fit(partial_x_train,
                    partial_y_train,
                    epochs=20,
                    batch_size=512,
                    validation_data=(x_val, y_val))

모델을 512개 샘플씩 미니 배치를 만들어 20번의 에포크 동안 훈련시킨다. x_train y_train 텐서에 있는 모든 샘플에 대해 20번 반복하게 된다. 동시에 따로 떼어 놓은 10,000개의 샘플에서 손실과 정확도를 측정할 것이다. 이를 위해서 validation_data 매개변수에 검증 데이터를 전달해야 한다.

model.fit() 메서드는 History 객체를 반환한다. 이 객체는 훈련하는 동안 발생한 모든 정보를 담고 있는 딕셔너리인 history 속성을 가지고 있다.

history_dict = history.history
history_dict.keys()

이 딕셔너리는 훈련과 검증하는 동안 모니터링할 측정 지표 당 하나씩, 모두 네 개의 항목을 담고 있다. matplotlib으로 훈련과 검증 데이터에 대한 손실과 정확도를 그릴 수 있다.

 

다음은 훈련 손실, 검증 손실 그래프이다.

loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(loss) + 1)

plt.plot(epochs, loss, 'bo', label='Training loss') #bo: 파란 점
plt.plot(epochs, val_loss, 'b', label='Validation loss') #b: 파란 실선
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()

plt.show()

다음은 훈련 정확도, 검증 정확도 그래프이다.

plt.clf()   # 그래프 초기화
accuracy = history.history['accuracy']
val_acc = history.history['val_accuracy']

plt.plot(epochs, accuracy, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()

plt.show()

점선은 훈련 손실과 정확도이고, 실선은 검증 손실과 정확도이다.

여기에서 볼 수 있듯이 훈련 손실이 에포크마다 감소하고 훈련 정확도는 에포크마다 증가한다. 경사 하강법 최적화를 사용했을 때 반복마다 최소화되는 것이 손실이므로 기대했던 결과가 나왔다.

검증 손실과 정확도는 이와 같지 않고, 4번째 에포크에서 그래프가 역전된다. 이것이 훈련 세트에서 잘 작동하는 모델이 처음 보는 데이터에 잘 작동하지 않을 수 있는 예시이다. 정확한 용어로는 과대 적합되었다고 말한다.

2번째 에포크 이후부터 훈련 데이터에 과도하게 최적화되어 훈련 데이터에 특화된 표현을 학습하므로 훈련 세트 이외의 데이터에는 일반화되지 못하는 현상이다.

 

이런 경우에 과대 적합을 방지하기 위해서 3번째 에포크 이후에 훈련을 중지할 수 있다. 일반적으로 과대적합을 완화하는 다양한 종류의 기술을 사용할 수 있다. 일단은 음부터 다시 새로운 신경망을 4번의 에포크 동안만 훈련하고 테스트 데이터에서 평가해 보는 식으로 하겠다. fit() 매서드를 재호출하면 학습된 가중치에서 훈련이 이어지므로, 처음부터 다시 학습하려면 모델 객체까지 새로 만들어야 한다.

model = models.Sequential()
model.add(layers.Dense(16, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(16, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['accuracy'])

model.fit(x_train, y_train, epochs=4, batch_size=512)
results = model.evaluate(x_test, y_test)

 

단순한 방식으로 88%의 정확도를 달성했다. 최고 수준의 기법을 사용하면 95%에 가까운 성능을 얻을 수 있다.

 

훈련된 모델로 새로운 데이터에 대해 예측하기

모델을 훈련시킨 후에는 이를 실전 환경에서 사용해 보아야 한다. predict 메서드를 사용해서 어떤 리뷰가 긍정일 확률을 예측할 수 있다.

이진 분류에서 레이블이 1인 경우를 양성(positive) 샘플, 0인 경우를 음성(negative) 샘플이라고 한다. 이 예에서는 긍정인 리뷰가 양성 샘플이지만, 꼭 좋은 쪽이 양성 샘플이 되는 것은 아니고 예측하려는 대상이 양성이다.

model.predict(x_test)

여기에서처럼 이 모델은 어떤 샘플에 대해 확신을 가지고 있지만(0.99 또는 그 이상, 0.01 또는 그 이하) 어떤 샘플에 대해서는 확신이 부족하다(0.04, 0.5 등)

 

정리

  • 원본 데이터를 신경망에 텐서로 주입하기 위해서는 꽤 많은 전처리가 필요하다. 단어 시퀀스는 이진 벡터로 인코딩 될 수 있고 다른 인코딩 방식도 있다.
  • relu 활성화 함수와 함께 Dense 층을 쌓은 네트워크는(감성 분류를 포함하여) 여러 종류의 문제에 적용할 수 있다.
  • 출력 클래스가 두 개인 이진 분류 문제에서 네트워크는 하나의 유닛과 시그모이드sigmoid 활성화 함수를 가진 Dense 층으로 끝나야 한다. 이 신경망의 출력은 확률을 나타내는 0과 1 사이의 스칼라 값이다.
  • 이진 분류 문제에서 이런 스칼라 시그모이드 출력에 대해 사용할 손실 함수는 binary_crossentropy이다.
  • rmsprop 옵티마이저는 문제에 상관없이 일반적으로 괜찮은 선택이다.
  • 훈련 데이터에 대해 성능이 향상됨에 따라 신경망은 과대 적합되기 시작하고, 이전에 본 적 없는 데이터에서는 결과가 점점 나빠지게 된다. 항상 훈련 세트 이외의 데이터에서 성능을 모니터링해야 한다.

 

반응형
Comments