안 쓰던 블로그

다중 분류: 케라스로 뉴스 기사 분류하기 본문

머신러닝/머신러닝

다중 분류: 케라스로 뉴스 기사 분류하기

proqk 2021. 4. 5. 17:16
반응형

이중 분류는 2개의 클래스가 있을 때 분류하는 방법이다. 그러면 2개 이상의 클래스가 있으면 어떻게 해야 할까?

클래스가 많은 문제는 다중 분류multiclass classification을 한다. 여기서는 로이터 뉴스를 46개의 상호 배타적인 토픽으로 분류하는 신경망을 예시로 다중 분류를 알아본다. 각 데이터 포인트가 정확히 하나의 범주로 분류되기 때문에 좀 더 정확히 말하면 단일 레이블 다중 분류 문제라고 할 수 있다. 각 데이터 포인트가 여러 개의 범주(예: 토픽)에 속할 수 있다면 다중 레이블 다중 분류 문제가 된다.

 

로이터 데이터셋

1986년에 로이터에서 공개한 짧은 뉴스 기사와 토픽의 집합인 로이터 데이터셋을 사용하겠다. 이 데이터셋은 텍스트 분류를 위해 널리 사용되는 간단한 데이터셋이다. 46개의 토픽이 있으며 어떤 토픽은 다른 것에 비해 데이터가 많다. 각 토픽은 훈련 세트에 최소한 10개의 샘플을 가지고 있다.

IMDB와 MNIST와 마찬가지로 로이터 데이터셋은 케라스에 포함되어 있다.

from keras.datasets import reuters

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

IMDB 데이터셋에서처럼 num_words=10000 매개변수는 데이터에서 가장 자주 등장하는 단어 10000개로 제한합니다.

여기에는 8982개의 훈련 샘플과 2246개의 테스트 샘플이 있으며, 각 샘플은 정수 리스트이다.

인코딩된 리스트를 단어로 디코딩하는 방법은 이전 글(foxtrotin.tistory.com/465)과 같다.

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

 

데이터 준비

데이터를 벡터로 변환한다. 코드는 이전 글과 같다.

import numpy as np

def vectorize_sequences(sequences, dimension=10000):
    results = np.zeros((len(sequences), dimension))
    for i, sequence in enumerate(sequences):
        results[i, sequence] = 1.
    return results

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

 

레이블을 벡터로 바꾸는 방법은 두 가지로, 레이블의 리스트를 정수 텐서로 변환하는 것과 원-핫 인코딩을 사용하는 것이 있다. 원-핫 인코딩이 범주형 데이터에 널리 사용되기 때문에 범주형 인코딩이라고도 부른다. 이 경우 레이블의 원-핫 인코딩은 각 레이블의 인덱스 자리는 1이고 나머지는 모두 0인 벡터이다.

def to_one_hot(labels, dimension=46):
    results = np.zeros((len(labels), dimension))
    for i, label in enumerate(labels):
        results[i, label] = 1.
    return results

one_hot_train_labels = to_one_hot(train_labels) #훈련 데이터->벡터
one_hot_test_labels = to_one_hot(test_labels) #테스트 데이터->벡터

x_train과 x_tes의 크기는 각각 (8982, 10000), (2246, 10000)이 되고, one_hot_train_labels와 one_hot_test_labels의 크기는 각각 (8982, 46), (2246, 46)이 된다.

from keras.utils.np_utils import to_categorical

one_hot_train_labels = to_categorical(train_labels)
one_hot_test_labels = to_categorical(test_labels)

케라스에는 이를 위한 내장 함수가 있기 때문에 이렇게 사용할 수 있다. to_one_hot() 함수는 labels 매개변수를 제외하고는 앞에서 사용한 vectorize_sequences()와 동일하다. 하지만 train_data와 test_data는 파이썬 리스트의 넘파이 배열이기 때문에 to_categorical() 함수를 사용할 수 없다.

 

모델 구성

이 토픽 분류 문제는 이전의 영화 리뷰 분류 문제와 둘 다 짧은 텍스트를 분류한다는 점에서 비슷해 보인다. 하지만 이 경우 출력 클래스의 개수가 2에서 46개로 늘어났다는 제약 사항이 걸렸다. 출력 공간의 차원이 훨씬 커진 것이다.

이전에 사용했던 것처럼 Dense 층을 쌓으면 각 층은 이전 층의 출력에서 제공한 정보만 사용할 수 있다. 한 층이 분류 문제에 필요한 일부 정보를 누락하면 그 다음 층에서 이를 복원할 방법이 없다. 즉, 각 층은 잠재적으로 정보의 병목이 될 수 있다.

이전 예제에서 16차원을 가진 중간층을 사용했지만 16차원 공간은 46개의 클래스를 구분하기에는 규모가 너무 작아서 정보의 병목 지점처럼 동작이 될 수 있다. 이런 이유로 64개의 유닛을 사용한 좀 더 규모가 큰 층을 사용한다.

from keras import models
from keras import layers

model = models.Sequential()
model.add(layers.Dense(64, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(46, activation='softmax'))

여기서 마지막 Dense 층의 크기가 46으로, 각 입력 샘플에 대해서 46차원의 벡터를 출력한다는 뜻이다. 이 벡터의 각 원소(각 차원)은 각기 다른 출력 클래스가 인코딩된 것이다.

또한 마지막 층에 softmax 활성화 함수가 사용되었다. 각 입력 샘플마다 46개의 출력 클래스에 대한 확률 분포를 출력한다. 즉, 46차원의 출력 벡터를 만들며 output[i]는 어떤 샘플이 클래스 i에 속할 확률이다. 46개의 값을 모두 더하면 1이 된다.

 

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

이런 문제에 사용할 최선의 손실 함수는 categorical_crossentropy이다. 이 함수는 두 확률 분포의 사이의 거리를 측정한다. 여기에서는 네트워크가 출력한 확률 분포와 진짜 레이블의 분포 사이의 거리를 측정할 것이다. 두 분포 사이의 거리를 최소화하면 진짜 레이블에 가능한 가까운 출력을 내도록 모델을 훈련하게 된다.

 

훈련 검증

훈련 데이터에서 1,000개의 샘플을 따로 떼어서 검증 세트로 사용한다.

x_val = x_train[:1000]
partial_x_train = x_train[1000:]

y_val = one_hot_train_labels[:1000]
partial_y_train = one_hot_train_labels[1000:]

 

20번의 에포크로 모델을 훈련시킨다.

history = model.fit(partial_x_train,
                    partial_y_train,
                    epochs=20,
                    batch_size=512,
                    validation_data=(x_val, y_val))

 

손실과 정확도 곡선을 그린다.

import matplotlib.pyplot as plt

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

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

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()

plt.show()

plt.clf() # 그래프 초기화

acc = history.history['accuracy']
val_acc = history.history['val_accuracy']

plt.plot(epochs, acc, '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()

이 모델은 9번째 에포크 이후에 과대적합이 시작된다.

 

9번의 에포크로 새로운 모델을 훈련하고 테스트 세트에서 평가하겠다. 모델 객체를 새로 만들고 9번까지만 학습을 한다.

model = models.Sequential()
model.add(layers.Dense(64, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(46, activation='softmax'))

model.compile(optimizer='rmsprop',
              loss='categorical_crossentropy',
              metrics=['accuracy'])
model.fit(partial_x_train,
          partial_y_train,
          epochs=9,
          batch_size=512,
          validation_data=(x_val, y_val))
results = model.evaluate(x_test, one_hot_test_labels)

결과는 대략 78%의 정확도를 달성했다.

균형 잡힌 이진 분류 문제에서 완전히 무작위로 분류하면 50%의 정확도를 달성한다고 한다.

이 문제는 불균형한 데이터셋을 사용하므로 무작위로 분류하면 19% 정도를 달성하는데 (아래 코드 참고), 여기에 비하면 이 결과는 꽤 좋은 편이다.

import copy

test_labels_copy = copy.copy(test_labels)
np.random.shuffle(test_labels_copy)
float(np.sum(np.array(test_labels) == np.array(test_labels_copy))) / len(test_labels)

결과: 0.19991095280498664

 

새로운 데이터에 대해 예측하기

모델 인스턴스의 predict 메서드는 46개 토픽에 대한 확률 분포를 반환한다. 테스트 데이터 전체에 대한 토픽을 예측해 본다.

predictions = model.predict(x_test)

predictions의 각 항목은 길이가 46인 벡터이다. 또한 이 벡터의 원소 합은 1이다. 가장 큰 값이 예측 클래스(=가장 확률이 높은 클래스)가 된다.

predictions[0].shape #(46,)

np.sum(predictions[0]) #1.0

np.argmax(predictions[0]) #3

참고1. 레이블과 손실을 다루는 다른 방법

앞서 말했던 두 개의 레이블을 인코딩하는 방법 중 다른 방법은 다음과 같이 정수 텐서로 변환하는 것이다. train_labels와 test_labels는 정수 타입의 넘파이 배열이기 때문에 다시 np.array() 함수를 사용할 필요는 없지만, 어쨌든 입력된 넘파이 배열의 복사본을 얻어 준다.

y_train = np.array(train_labels)
y_test = np.array(test_labels)

이 방식을 사용하려면 손실 함수 하나만 바꾸면 된다. 아까 사용한 손실 함수 categorical_crossentropy는 레이블이 범주형 인코딩되어 있을 것이라고 기대하기 때문에, 정수 레이블을 사용할 때는 sparse_categorical_crossentropy를 사용해야 한다.

model.compile(optimizer='rmsprop', loss='sparse_categorical_crossentropy', metrics=['acc'])

이 손실 함수는 인터페이스만 다를 뿐이고 수학적으로는 categorical_crossentropy와 동일하다.

 

참고2. 충분히 큰 중간층을 두어야 하는 이유

마지막 출력이 46차원이기 때문에 중간층의 히든 유닛이 46개보다 많이 적어서는 안 된다. 46차원보다 훨씬 작은 중간층(예를 들면 4차원)을 두면 정보의 병목이 나타난다.

model = models.Sequential()
model.add(layers.Dense(64, activation='relu', input_shape=(10000,)))
model.add(layers.Dense(4, activation='relu')) #여기
model.add(layers.Dense(46, activation='softmax'))

model.compile(optimizer='rmsprop',
              loss='categorical_crossentropy',
              metrics=['accuracy'])
model.fit(partial_x_train,
          partial_y_train,
          epochs=20,
          batch_size=128,
          validation_data=(x_val, y_val))

검증 정확도의 최고 값은 약 71%로 7% 정도 감소되었다. 이런 손실의 대부분 원인은 많은 정보(46개 클래스의 분할 초평면을 복원하기에 충분한 정보)를 중간층의 저차원 표현 공간으로 압축하려고 했기 때문에 사라져 버린 것이다. 이 네트워크는 필요한 정보 대부분을 4차원 표현 안에 욱여 넣었지만 전부는 넣지 못했다.

 

정리

  • N개의 클래스로 데이터 포인트를 분류하려면 네트워크의 마지막 Dense 층의 크기는 N이어야 한다.
  • 단일 레이블, 다중 분류 문제에서는 N개의 클래스에 대한 확률 분포를 출력하기 위해 softmax 활성화 함수를 사용해야 한다.
  • 이런 문제에는 항상 범주형 크로스엔트로피를 사용해야 한다. 이 함수는 모델이 출력한 확률 분포와 타깃 분포 사이의 거리를 최소화한다.
  • 다중 분류에서 레이블을 다루는 두 가지 방법이 있다
    • 레이블을 범주형 인코딩(또는 원-핫 인코딩)으로 인코딩하고 categorical_crossentropy 손실 함수를 사용하는 방법
    • 레이블을 정수로 인코딩하고 sparse_categorical_crossentropy 손실 함수를 사용하는 방법
  • 많은 수의 범주를 분류할 때 중간층의 크기가 너무 작아 네트워크에 정보의 병목이 생기지 않도록 해야 한다.
반응형
Comments