본문 바로가기

AI/NLP Study

[NLP] 텍스트 카테고리 분류

로이터 뉴스데이터에 대해 카테고리 분류를 진행하겠습니다.

이는 텐서플로우 데이터셋에서 제공하고 있는 데이터로 아주 쉽게 다운로드가 가능합니다.

필요한 모듈을 임포트하고 데이터를 다운로드하여 오겠습니다.

from tensorflow.keras.datasets import reuters
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd

(x_train, y_train), (x_test, y_test) = reuters.load_data(num_words=10000, test_split=0.2)

tensorflow.keras.datasets.reuters.load_data에는 다양한 파라미터가 있습니다. 자세한 내용은 공식 문서에 나오겠지만 간단하게 적어 두겠습니다. (나중에 제가 보고 참고하려고요... ㅋㅋ)

  • num_words : 정수로 설정하거나 따로 설정 안 해도 됨. 정수로 설정하면 빈도수가 가장 높은 단어(훈련 세트에서) 순서로 정수 개수만큼 저장. 개수 넘어가는 단어들은 oov_char로 저장됨
  • skip_top : 지정한 개수만큼 건너뜀(5개면 제일 빈도수 높은 상위 5개는 건너뜀). 건너뛴 단어는 oov_char로 저장됨. 아마 불용어 제거할 때 사용할 것 같음
  • max_len : 최대 문장 길이
  • test_split : 시험 데이터로 사용할 비율
  • start_char : 문장 시작 단어 지정. 기본값은 1. 0은 보통 패딩 된 부분을 표현하기 때문임
  • oov_char : num_words, skip_top 설정으로 oov_char로 저장된 단어들을 표현할 숫자. 기본값 2

정도의 파라미터가 있고, 함수 실행하면 (x_train, y_train), (x_test, y_test)의 넘 파이 어레이로 된 튜플을 반환한다고 합니다.

 

데이터를 한 번 뽑아서 확인해보겠습니다.

print(x_train[0])
print(x_test[0])

보면 모두 1로 시작하는 것을 알 수 있습니다. 그리고 중간에 보이는 2는 아마 oov_char일 겁니다.

그리고 단어로 불러오는 게 아닌 번호로 불러오는 것을 알 수 있습니다. 따로 전처리를 하지 않아도 되겠네요.. 😀

그럼 클래스(레이블)의 개수를 확인해보겠습니다.

num_classes = max(y_train) + 1
print('클래스의 수 : {}'.format(num_classes))

# output
클래스의 수 : 46

데이터의 길이 분포도 한 번 보겠습니다.

print(f'max train data len : {max(len(l) for l in x_train)}')
print(f'average train data len : {sum(map(len, x_train))/len(x_train)}')

plt.hist([len(s) for s in x_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

평균 길이가 145인데 최대가 2376입니다. 아마 뉴스가 길어지면 끝도 없이 길어지는 모양입니다 ㅋㅋ

 

이번에는 데이터에서 클래스의 분포를 보겠습니다. 특성 클래스에만 데이터가 몰려있으면 분류에 영향을 미칠 수 있기 때문입니다.

fig, axe = plt.subplots(ncols=1)
fig.set_size_inches(11,5)
sns.countplot(x=y_train)
plt.show()

np.unique() 함수를 통해 실제 숫자로 확인해보겠습니다.

unique_elements, counts_elements = np.unique(y_train, return_counts=True)
print('각 클래스 빈도수 : ')
print(np.asarray((unique_elements, counts_elements)))

근데 지금 데이터가 다 정수화 되어있어서 실제 뉴스의 내용을 모른다는 단점이 있습니다. 정수를 단어로 바꿔주는 함수를 정의해서 실제 텍스트를 확인해보겠습니다.

word_index = reuters.get_word_index(path="reuters_word_index.json")

index_to_word = { index+3 : word for word, index in word_index.items() }

# index_to_word에 숫자 0은 <pad>, 숫자 1은 <sos>, 숫자 2는 <unk>를 넣어줍니다.
for index, token in enumerate(("<pad>", "<sos>", "<unk>")):
  index_to_word[index]=token
  
# 확인
print(' '.join([index_to_word[index] for index in x_train[0]]))

이제 인덱스를 단어로 바꿔주는 딕셔너리도 정의했으니 전체 데이터를 한 번 텍스트로 바꿔 보겠습니다.

decoded_train = []
for i in range(len(x_train)):
  t = ' '.join([index_to_word[index] for index in x_train[i]])
  decoded_train.append(t)

x_train = decoded_train

decoded_test = []
for i in range(len(x_test)):
  text = ' '.join([index_to_word[index] for index in x_test[i]])
  decoded_test.append(text)

x_test = decoded_test

이제 만들어진 텍스트 데이터를 이용해 벡터화를 해보겠습니다.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer

# 1 : DTM 생성
dtmvector = CountVectorizer()
x_train_dtm = dtmvector.fit_transform(x_train)
print(x_train_dtm.shape)

# output
(8982, 9670)

# 2 : Tfidf 행렬 생성
tfidf_matrix = TfidfTransformer()
x_train_tfidf = tfidf_matrix.fit_transform(x_train_dtm)
print(x_train_tfidf.shape)

# output
(8982, 9670)

이제 다양한 모델을 사용해서 분류하기 위해 필요한 모델들을 불러오겠습니다.

from sklearn.naive_bayes import MultinomialNB # 다항분포 나이브 베이즈 모델
from sklearn.linear_model import LogisticRegression, SGDClassifier
from sklearn.naive_bayes import ComplementNB
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import VotingClassifier
from sklearn.svm import LinearSVC
from sklearn.metrics import accuracy_score
  • 나이브 베이즈 분류기(Multinomial Naive Bayes Classifier)

https://www.youtube.com/watch?v=3JWLIV3NaoQ 

나이브 베이즈 분류기를 사용하여 해당 기사에 대한 카테고리를 예측해보겠습니다.

x_test_dtm = dtmvector.transform(x_test)
x_test_tfidf = tfidf_matrix.transform(x_test_dtm)


model_NB = MultinomialNB()
model_NB.fit(x_train_tfidf, y_train)

predicted_NB = model_NB.predict(x_test_tfidf)
print('정확도 : ', accuracy_score(y_test, predicted_NB))

# output
정확도 :  0.6567230632235085

먼저 예측을 하기 전에 테스트 데이터도 훈련 데이터와 마찬가지로 TF-IDF 행렬로 바꿔줘야 합니다. 

하지만 테스트 데이터를 TF-IDF로 만들어 줄 때는 fit_transform이 아닌 transform을 사용해야 합니다.

이에 대한 설명은 다음 블로그에 자세히 나와있습니다!

https://deepinsight.tistory.com/165

 

[scikit-learn] transform()과 fit_transform()의 차이는 무엇일까?

왜 scikit-learn에서 모델을 학습할 때, train dataset에서만 .fit_transform()메서드를 사용하는 건가요? TL;DR 안녕하세요 steve-lee입니다. 실용 머신러닝 A to Z 첫번 째 시간은 scikit-learn에서 자주 사용..

deepinsight.tistory.com

 

앞에서 모델의 성능을 accuracy를 이용해 측정했습니다. 하지만 그 외에도 모델 성능 측정에 대한 방법으로 F-1 Score가 있습니다.

이에 대해서도 정리가 잘 되어있는 블로그가 이미 있기 때문에 설명은 블로그 링크로 대신하겠습니다!

https://sumniya.tistory.com/26

 

분류성능평가지표 - Precision(정밀도), Recall(재현율) and Accuracy(정확도)

기계학습에서 모델이나 패턴의 분류 성능 평가에 사용되는 지표들을 다루겠습니다. 어느 모델이든 간에 발전을 위한 feedback은 현재 모델의 performance를 올바르게 평가하는 것에서부터 시작합니

sumniya.tistory.com

사이킷런의 metrics 패키지에는 정밀도, 재현율, F1-score를 구하는 classification_report() 함수를 제공합니다. 

한 번 어떻게 나오는지 확인해보겠습니다.

from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix

print(classification_report(y_test, model_NB.predict(x_test_tfidf), zero_division=0))
              precision    recall  f1-score   support


           0       0.00      0.00      0.00        12
           1       0.62      0.69      0.65       105
           2       0.00      0.00      0.00        20
           3       0.81      0.90      0.85       813
           4       0.51      0.96      0.67       474
           5       0.00      0.00      0.00         5
           6       0.00      0.00      0.00        14
           7       0.00      0.00      0.00         3
           8       0.00      0.00      0.00        38
           9       1.00      0.08      0.15        25
          10       0.00      0.00      0.00        30
          11       0.66      0.63      0.64        83
          12       0.00      0.00      0.00        13
          13       1.00      0.03      0.05        37
          14       0.00      0.00      0.00         2
          15       0.00      0.00      0.00         9
          16       0.69      0.56      0.61        99
          17       0.00      0.00      0.00        12
          18       0.00      0.00      0.00        20
          19       0.60      0.78      0.68       133
          20       1.00      0.04      0.08        70
          21       0.00      0.00      0.00        27
          22       0.00      0.00      0.00         7
          23       0.00      0.00      0.00        12
          24       0.00      0.00      0.00        19
          25       1.00      0.03      0.06        31
          26       0.00      0.00      0.00         8
          27       0.00      0.00      0.00         4
          28       0.00      0.00      0.00        10
          29       0.00      0.00      0.00         4
          30       0.00      0.00      0.00        12
          31       0.00      0.00      0.00        13
          32       0.00      0.00      0.00        10
          33       0.00      0.00      0.00         5
          34       0.00      0.00      0.00         7
          35       0.00      0.00      0.00         6
          36       0.00      0.00      0.00        11
          37       0.00      0.00      0.00         2
          38       0.00      0.00      0.00         3
          39       0.00      0.00      0.00         5
          40       0.00      0.00      0.00        10
          41       0.00      0.00      0.00         8
          42       0.00      0.00      0.00         3
          43       0.00      0.00      0.00         6
          44       0.00      0.00      0.00         5
          45       0.00      0.00      0.00         1


    accuracy                               0.66      2246
   macro avg       0.17      0.10   0.10       2246
weighted avg     0.59     0.66   0.58      2246

제일 아래쪽 최종 결과에서 각각이 의미하는 바는 다음과 같습니다.

  • macro : 단순 평균
  • weighted : 각 클래스에 속하는 표본의 개수로 가중 평균
  • accuracy : 정확도.

이번엔 confusion matrix를 시각화해보겠습니다.

시각화로 seaborn의 heatmap을 이용하겠습니다.

def graph_confusion_matrix(model, x_test, y_test):
  df_cm = pd.DataFrame(confusion_matrix(y_test, model.predict(x_test)))
  fig = plt.figure(figsize=(12,12))
  heatmap = sns.heatmap(df_cm, annot=True, fmt='d')
  heatmap.yaxis.set_ticklabels(heatmap.yaxis.get_ticklabels(), rotation=0, ha='right', fontsize=12)
  heatmap.xaxis.set_ticklabels(heatmap.xaxis.get_ticklabels(), rotation=45, ha='right', fontsize=12)
  plt.ylabel('label')
  plt.xlabel('predicted value')


graph_confusion_matrix(model_NB, x_test_tfidf, y_test)

  • Complement Naive Bayes Classifier(CNB)

나이브 베이즈 분류기는 독립 변수가 '조건부로 독립적'이라는 가정을 하기 때문에, 많은 데이터가 특정 클래스에 치우쳐져 있을 경우, 결정 경계의 가중치가 한쪽으로 치우칠 수 있습니다. 

이러한 문제 때문에 나이브 베이즈 분류기를 보완한 것이 Complement Naive Bayes Classifier(CNB)입니다. 이 모델은 데이터의 불균형을 고려하여 가중치를 부여하는 특징을 가지고 있습니다.

cb = ComplementNB()
cb.fit(x_train_tfidf, y_train)

predicted = cb.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# output
0.7707034728406055

77%의 정확도를 얻었습니다. 기존 나이브 베이즈 분류기를 사용했을 때보다 10% 이상 높은 성능입니다.

 

 

로지스틱 회귀는 소프트맥스(softmax) 함수를 사용한 다중 클래스 분류 알고리즘을 지원합니다. 다중 클래스 분류를 위한 로지스틱 회귀를 소프트맥스 회귀(softmax regression)라고도 합니다.

사이킷런에서 로지스틱 회귀는 LogisticRegression()을 통해서 구현할 수 있습니다.

lr = LogisticRegression(C=10000, penalty='l2', max_iter=3000)
lr.fit(x_train_tfidf, y_train)

predicted = lr.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# output
0.8107747105966162

또 다른 모델로 서포트 벡터 머신이 있습니다. 여기서 서포트 벡터란 Decision Boundary와 가장 가까운 각 클래스의 데이터를 의미합니다.

원래 이진 분류만을 지원하는 이진 분류 모델이지만 일대다(OVA) 방법을 통해 다중 클래스 분류를 진행할 수 있습니다. 

각 클래스를 2개씩 뽑아와서 분류하고 그중에서 가장 점수가 높은 분류기의 클래스를 예측값으로 선택합니다.

예를 들어 클래스 1과 2를 분류하는 분류기, 1과 3을 분류하는 분류기,... , 40과 41을 분류하는 분류기를 만들어 분류를 진행하고 가장 높은 점수를 내는 분류기의 클래스를 예측값으로 선택하는 것입니다.

lsvc = LinearSVC(C=1000, penalty='l1', max_iter=3000, dual=False)
lsvc.fit(x_train_tfidf, y_train)

predicted = lsvc.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# outputs
0.7894033837934105
  • 결정 트리(Decision Tree)

사이킷런의 DecisionTreeClassifier()를 이용해 결정 트리를 구현할 수 있습니다. 결정 트리의 깊이는 max_depth라는 인자로 정해줄 수 있습니다.

tree = DecisionTreeClassifier(max_depth=10, random_state=27)
tree.fit(x_train_tfidf, y_train)

predicted = tree.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# output
0.6228851291184327
  • 랜덤 포레스트(Random Forest)

앙상블(Ensemble)이란 여러 머신러닝 모델을 연결하여 더 강력한 모델을 만드는 기법입니다. 

forest = RandomForestClassifier(n_estimators = 5, random_state=27)
forest.fit(x_train_tfidf, y_train)

predicted = forest.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# output
0.6674087266251113
  • 그래디언트 부스팅 트리(Gradient Boosting Classifier)

그래디언트 부스팅은 이전 트리의 오차를 보완하는 방식으로 트리를 만듭니다.

일반적으로 1~5 정도의 깊지 않은 트리를 사용하므로 메모리도 적게 사용하고 예측도 빠릅니다.

하지만, 훈련 시간이 좀 오래 걸리고, 희소한 고차원 데이터에 대해서는 잘 동작하지 않는다는 단점이 있습니다.

 

TF-IDF 행렬은 희소하고 고차원 데이터이지만, 그래도 혹시나 성능이 괜찮게 나올 수 있으니 한 번 사용해보겠습니다.

from sklearn.ensemble import GradientBoostingClassifier

grbt = GradientBoostingClassifier(random_state=27, verbose=3)
grbt.fit(x_train_tfidf, y_train)

predicted = grbt.predict(x_test_tfidf)
print(accuracy_score(y_test, predicted))

# output
0.7662511130899377
  • 보팅(Voting)

보팅에는 하드 보팅과 소프트 보팅 두 가지로 나뉩니다.

간단하게 설명하자면 아래와 같습니다.

  • 하드 보팅 : 각 분류기들이 분류한 클래스 중 가장 많이 분류한 클래스로 결정 ( 예를 들어 분류기들의 결과 [1,1,2,1] -> 보팅 결과 예측 클래스 = 1)
  • 소프트 보팅 : 각 분류기들이 분류한 확률의 평균을 구하고 그 평균을 낸 확률에 대해서 분류

소프트 보팅 예)

  클래스 1 클래스 2  
분류기 1 0.2 0.8  
분류기 2 0.3 0.7  
분류기 3 0.7 0.3  
분류기 4 0.4 0.6  
확률 평균 0.4 0.6 최종 분류 클래스 = 클래스 2

지금까지 사용했던 로지스틱 회귀, CNB, 그래디언트 부스팅 트리를 사용하여 소프트 보팅을 하였을 때의 성능을 보겠습니다.

voting_classifier = VotingClassifier(estimators=[
         ('lr', LogisticRegression(C=10000, max_iter=3000, penalty='l2')),
        ('cb', ComplementNB()),
        ('grbt', GradientBoostingClassifier(random_state=0))
], voting='soft')
voting_classifier.fit(x_train_tfidf, y_train)

predicted = voting_classifier.predict(x_test_tfidf) #테스트 데이터에 대한 예측
print("정확도:", accuracy_score(y_test, predicted)) #예측값과 실제값 비교

# output
0.8165627782724845

지금까지 사용 모델과 결과를 한 번 정리해보겠습니다.

  나이브 베이즈 분류기 CNB 로지스틱 회귀 서포트 벡터 머신 결정 트리 랜덤 포레스트 그래디언트 부스팅 트리 보팅
accuracy 0.6567 0.7707 0.8107 0.7894 0.6228 0.6674 0.7693 0.8165

 

이제 조건을 바꿔가며 어떤 조건에서 가장 최적의 성능이 나오는지 보겠습니다. 그리고 그 조건에서 딥러닝 모델과 성능을 비교해보겠습니다.

  • 조건 1 : 모든 단어로 단어장 구성하여 결과 보기
from sklearn.metrics import f1_score

# 1 : 모든 단어로 단어장 구성
(x_train, y_train), (x_test, y_test) = reuters.load_data(num_words=None, test_split=0.2)

# train, test 문자로 바꾸기
decoded_train = []
for i in range(len(x_train)):
  t = ' '.join([index_to_word[index] for index in x_train[i]])
  decoded_train.append(t)

x_train = decoded_train

decoded_test = []
for i in range(len(x_test)):
  text = ' '.join([index_to_word[index] for index in x_test[i]])
  decoded_test.append(text)

x_test = decoded_test

# tfidf 생성
# train
x_train_dtm = dtmvector.fit_transform(x_train)
x_train_tfidf = tfidf_matrix.fit_transform(x_train_dtm)

# test
x_test_dtm = dtmvector.transform(x_test)
x_test_tfidf = tfidf_matrix.transform(x_test_dtm)


# 모델 별 결과를 저장할 리스트 생성
result_acc = [] # 정확도 리스트
result_f1score = [] # f1score 리스트

# 모델 리스트
model_nb = MultinomialNB()
model_cnb = ComplementNB()
model_lr = LogisticRegression(C=10000, penalty='l2', max_iter=3000)
model_lsvc = LinearSVC(C=1000, penalty='l1', max_iter=3000, dual=False)
model_tree = DecisionTreeClassifier(max_depth=10, random_state=27)
model_forest = RandomForestClassifier(n_estimators = 5, random_state=27)
model_grbt = GradientBoostingClassifier(random_state=27, verbose=3)
voting_classifier = VotingClassifier(estimators=[
         ('lr', LogisticRegression(C=10000, max_iter=3000, penalty='l2')),
        ('cb', ComplementNB()),
        ('grbt', GradientBoostingClassifier(random_state=0))
], voting='soft')


model_list = [model_nb, model_cnb, model_lr, model_lsvc, model_forest, model_grbt, voting_classifier]

for model in model_list:
  model.fit(x_train_tfidf, y_train)
  y_pred = model.predict(x_test_tfidf)

  acc = accuracy_score(y_test, y_pred)
  f_score = f1_score(y_test, y_pred, average='weighted')

  result_acc.append(acc)
  result_f1score.append(f_score)


result_df = pd.DataFrame(zip(result_acc, result_f1score), index=model_list, columns=['accuracy', 'f1_score'])
result_df

  • 조건 2 : 단어장 크기 5000으로 진행

구현 코드 부분에서 단어장의 크기만 바뀌고 달라진 것은 없기 따로 작성은 안 하고 결과만 보겠습니다.

  • 조건 3 : 단어장 크기 5000, skip_top = 5

  • 조건 4 : 단어장 크기 10000, skip_top = 3

  • RNN을 이용한 딥러닝 모델과 비교

이미 전처리가 된 데이터라는걸 앞에서 알고 있기 때문에 따로 전처리는 하지 않고 패딩만 추가해준 뒤 모델 학습을 진행해보겠습니다.

import tensorflow as tf

x_train = tf.keras.preprocessing.sequence.pad_sequences(x_train,
                                                        value=0,
                                                        padding='pre',
                                                        maxlen=max_len)
x_test = tf.keras.preprocessing.sequence.pad_sequences(x_test,
                                                       value=0,
                                                       padding='pre',
                                                       maxlen = max_len)
vocab_size = vocab_size
word_vector_dim = 200

model = tf.keras.Sequential()
model.add(tf.keras.layers.Embedding(vocab_size, word_vector_dim, input_shape=(None,)))
model.add(tf.keras.layers.LSTM(128))
model.add(tf.keras.layers.Dense(64, activation='relu'))
model.add(tf.keras.layers.Dense(46, activation='softmax')) # 클래스가 총 46개라 마지막층은 46개의 결과가 나와야합니다. 

model.summary()

model.compile(optimizer='adam',
              loss='SparseCategoricalCrossentropy', # 라벨이 46개이기 때문에 손실함수를 전과 다르게 사용합니다.
              metrics=['accuracy'])

model.fit(x_train, y_train, epochs=15, batch_size=64, validation_data=(x_test, y_test), verbose=1)

결과 정리 및 회고

당연히 딥러닝을 이용한 모델이 성능이 제일 좋을 줄 알았는데 머신러닝을 이용했을 때가 성능이 더 좋았습니다.

  분류 모델 딥러닝 모델
단어 전부 사용 accuracy  0.817008 0.6915

 

결과를 보고 나니 무조건 딥러닝이 정답은 아니다는 걸 배울 수 있었던 것 같습니다.

최근에 딥러닝 관련 공부만 하다 보니까 초반에 배운 분류, 회귀모델이 가물가물했었는데 이번 기회에 다시 복습도 하게 되어 좋았습니다.

 

앞으로도 실습을 진행하거나 프로젝트를 할 때 딥러닝 모델뿐 아니라 이 전의 분류, 회귀모델의 사용도 고려해봐야 할 것 같습니다.

'AI > NLP Study' 카테고리의 다른 글

[NLP] 임베딩 편향성  (0) 2022.10.02
[NLP] 워드 임베딩  (1) 2022.10.01
[NLP] 텍스트 벡터화  (0) 2022.09.27
[NLP] 단어사전 만들기  (1) 2022.09.25
[NLP] 자연어 전처리 : 토큰화  (3) 2022.09.21