안 쓰던 블로그
다중 분류: 케라스로 뉴스 기사 분류하기 본문
이중 분류는 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 손실 함수를 사용하는 방법
- 많은 수의 범주를 분류할 때 중간층의 크기가 너무 작아 네트워크에 정보의 병목이 생기지 않도록 해야 한다.
'머신러닝 > 머신러닝' 카테고리의 다른 글
머신 러닝의 기본 개념들 (1) | 2021.04.05 |
---|---|
회귀 문제: 케라스로 주택 가격 예측하기 (0) | 2021.04.05 |
이진 분류: 케라스로 영화 리뷰 분류하기 (0) | 2021.04.04 |
kaggle - Predict survival on the Titanic with DecisionTreeClassifier (0) | 2021.02.08 |
kaggle - Santander Customer Satisfaction 예측 (0) | 2021.02.08 |