NLP

NLP 실습 텍스트 분류 -01

영어 텍스트 분류

  • 한국어는 띄어쓰기를 기준으로 모든 단어를 처리할 수 없으므로 상대적으로 전처리하기 쉬운 영어 텍스트를 가지고 먼저 감각을 키워보겠다.

문제 소개

영어 텍스트 분류 문제 중 캐글의 대회인 워드팝콘 문제를 활용할 것이다. 이 문제를 해결하면서 텍스트 분류 기술을 알아볼것이다.

워드 팝콘

  • 워드 팝콘은 인터넷 영화 데이터베이스(IMDB)에서 나온 영화 평점 데이터를 활용한 캐글 문제다. 영화 평점 데이터이므로 각 데이터는 영화 리뷰 텍스트와 평점에 따른 감정 값(긍정 혹은 부정)으로 구성돼 있다. 이 데이터는 보통 감성 분석(sentiment analysis) 문제에서 자주 활용된다.

목표

  • 1) 데이터를 불러오고 정제되지 않은 데이터를 활용하기 쉽게 전처리하는 과정
  • 2) 데이터 분석 과정
    • 데이터를 분석하여 어떻게 문제를 풀어가야 할지 접근하는 과정
  • 3) 실제 문제를 해결하기 위해 알고리즘을 모델링하는 과정

캐글 API를 colab에서 사용하기 위한 인증 및 google storage에 업로드 되어있는 인증키 파일 현재 colab pwd로 복사해온 후 설정완료하기

1
2
3
4
5
6
7
8
from google.colab import auth
import warnings
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
warnings.filterwarnings("ignore")
auth.authenticate_user()

!gsutil cp gs://kaggle_key/kaggle.json kaggle.json
1
2
3
4
5
6
7
!mkdir -p ~/.kaggle
!mv ./kaggle.json ~/.kaggle/
!chmod 600 ~/.kaggle/kaggle.json
!pip install kaggle

# 캐글 competition 목록확인
#!kaggle competitions list
  • 목표 competition의 데이터 다운로드
1
2
3
4
5
# 파일 확인
!kaggle competitions files -c word2vec-nlp-tutorial

# 파일 다운로드
!kaggle competitions download -c word2vec-nlp-tutorial

데이터 분석 및 전처리

  • 모델을 학습시키기 전에 데이터를 전처리하는 과정을 거쳐야 한다. 전처리는 데이터를 모델에 적용하기에 적합하도록 데이터를 정제하는 과정이다. 그전에 데이터를 불러오고 분석하는 과정을 선행할 것이다. EDA과정을 거친 후 분석 결과를 바탕으로 전처리 작업을 할 것이다.

  • 참고로 데이터를 불러오는데 403 error가 출력된다면, 우선적으로 대회의 rule을 check했는지 확인해 보아야 한다.

    • sampleSubmission.csv 파일을 제외한 나머지 파일이 zip으로 압축돼 있기 때문에 압축을 푸는 과정부터 시작한다. 압축을 풀기 위해 zipfile이라는 내장 라이브러리를 사용할 것이다.
- 압축을 풀기 위해 경로와 압축을 풀 파일명을 리스트로 선언한 후 반복문을 사용해 압축을 풀 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
import zipfile
DATA_IN_PATH = '/content/'

file_list = ['labeledTrainData.tsv.zip', 'testData.tsv.zip', 'unlabeledTrainData.tsv.zip']

for file in file_list:
# 압축풀기 대상 설정 및 모드 설정
zipRef = zipfile.ZipFile(DATA_IN_PATH + file, 'r')
# 압축 풀기 및 저장 경로 설정
zipRef.extractall(DATA_IN_PATH)
# 호출 종료
zipRef.close()
1
2
3
4
5
6
7
8
import numpy as np
import pandas as pd
import os
import matplotlib.pyplot as plt
import seaborn as sns

# 그래프를 바로 그리도록 함
%matplotlib inline
  • 현재 사용할 데이터는 tap(\t)으로 구분돼 있으므로 delimeter=’\t’로 설정해주었고, 각 데이터에 각 항목명(Header)이 포함돼 있기 때문에 header인자에 0을 설정한다. R에서는 header=Ture로 하는 역할과 같다고 보면된다. 그리고 쌍따옴표를 무시하기 위해 quoting=3을 설정해 주었다.
1
2
3
train_data = pd.read_csv(DATA_IN_PATH+"labeledTrainData.tsv", header=0, delimiter='\t', quoting=3)

train_data.head()

데이터 분석 진행 순서

  • 1) 데이터 크기
  • 2) 데이터의 개수
  • 3) 각 리뷰의 문자 길이 분포
  • 4) 많이 사용된 단어
  • 5) 긍정, 부정 데이터의 분포
  • 6) 각 리뷰의 단어 개수 분포
  • 7) 특수문자 및 대문자, 소문자 비율
1
2
3
4
5
6
7
8
9
10
11
# 데이터 크기
print("파일 크기 : ")
for file in os.listdir(DATA_IN_PATH):
if 'tsv' in file and 'zip' not in file:
print(file.ljust(30) + str(round(os.path.getsize(DATA_IN_PATH + file) / 1000000, 2)) + 'MB')

# 학습 데이터의 개수
print('전체 학습 데이터의 개수: {}'.format(len(train_data)))

# 결과
# 전체 학습 데이터의 개수: 25000
  • 각 review의 길이를 분석
1
2
train_length = train_data['review'].apply(len)
train_length.head()
  • 각 리뷰의 문자 길이가 대부분 6,000 이하이고 대부분 2,000이하에 분포돼 있음을 알 수 있다. 그리고 일부 데이터의 경우 이상치로 10,000 이상의 값을 가지고 있다.
1
2
3
4
5
6
7
8
9
10
11
12
plt.figure(figsize=(12, 5))

plt.hist(train_length, bins=200, alpha=0.5, color='r', label='word')

# y축의 범위를 log단위로 바꿔주고 non-positive에 대해서는 아주작은 양수로 클리핑한다.
plt.yscale('log', nonposy='clip')

plt.title('Log-Histogram of length of review')

plt.xlabel('Length of review')

plt.ylabel('Number of review')

각 review의 길이에 대한 히스토그램

1
2
# 기초 통계량 확인
train_length.describe()
1
2
3
plt.figure(figsize=(12, 5))

plt.boxplot(train_length, labels=['train data review length'], showmeans=True)

학습 데이터의 길이에 대한 boxplot

  • wordcloud를 통해 시각적으로 빈도수를 확인하기 위해 설치한다.

  • 워드 클라우드를 보면 가장 많이 사용된 단어는 br이라는 것을 확인할 수 있다. HTML 태그인 br 해당 데이터가 높은 빈도수를 보이는 것으로 미루어보아 정제되지 않은 인터넷 상의 리뷰 형태로 작성돼 있음을 알 수 있다. 이후 전처리 작업에서 이 태그들을 모두 제거하겠다.

1
!pip install wordcloud
1
2
3
4
5
from wordcloud import WordCloud
cloud = WordCloud(width=800, height=600).generate(' '.join(train_data['review']))
plt.figure(figsize=(20, 15))
plt.imshow(cloud)
plt.axis('off')

학습데이터에 대한 wordcloud

  • 이제 각 라벨의 분포를 확인해 본다. 해당 데이터의 경우 긍정과 부정이라는 두 가지 라벨만 가지고 있다. 분포의 경우 또 다른 시각화 도구인 seaborn을 사용해 시각화하겠다.

  • label의 분포 그래프를 보면 거의 동일한 개수로 분포돼 있음을 확인 할 수 있다.

1
2
3
fig, axe = plt.subplots(ncols=1)
fig.set_size_inches(6, 3)
sns.countplot(train_data['sentiment'])

긍정 부정에 대한 count plot

1
2
3
4
5
6
print("긍정 리뷰 개수: {}".format(train_data['sentiment'].value_counts()[1]))
print("부정 리뷰 개수: {}".format(train_data['sentiment'].value_counts()[0]))

# 결과
# 긍정 리뷰 개수: 12500
# 부정 리뷰 개수: 12500
  • 각 리뷰를 단어 기준으로 나눠서 각 리뷰당 단어의 개수를 확인해 본다. 단어는 띄어쓰기 기준으로 하나의 단어라 생각하고 개수를 계산한다. 우선 각 단어의 길이를 가지는 변수를 하나 설정하자.
1
train_word_counts = train_data['review'].apply(lambda x: len(x.split(' ')))

대부분의 단어가 1000개 미만의 단어를 가지고 있고, 대부분 200개 정도의 단어를 가지고 있음을 확인할 수 있다.

1
2
3
4
5
6
7
plt.figure(figsize=(15,10))
plt.hist(train_word_counts, bins=50, facecolor='r', label='train')
plt.title('Log-Histogram of word count in review', fontsize=15)
plt.yscale('log', nonposy='clip')
plt.legend()
plt.xlabel('Number of words', fontsize=15)
plt.ylabel('Number of reviews', fontsize=15)

공백을 기준으로 분리한 각 review가 갖는 단어 수에 대한 histogram

review의 75%가 300개 이하의 단어를 가지고 있음을 확인 할 수 있다.

1
train_word_counts.describe()
  • 마지막으로 각 review에 대해 구두점과 대소문자 비율 값을 확인한다.

  • 대부분 마침표를 포함하고 있고, 대문자도 대부분 사용하고 있다. 따라서 전처리 과정에서 대문자의 경우 모두 소문자로 바꾸고 특수 문자의 경우 제거한다. 이 과정은 학습에 방해가 되는 요소들을 제거하기 위함이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 물음표가 구두점으로 사용되는 비율
qmarks = np.mean(train_data['review'].apply(lambda x : '?' in x))
# 마침표가 구두점으로 사용되는 비율
fullstop = np.mean(train_data['review'].apply(lambda x : '.' in x))
# 첫 번째 대문자의 비율
capital_first = np.mean(train_data['review'].apply(lambda x : x[0].isupper()))
# 대문자 비율
capitals = np.mean(train_data['review'].apply(lambda x : max([y.isupper() for y in x])))
# 숫자 비율
numbers = np.mean(train_data['review'].apply(lambda x : max([y.isdigit() for y in x])))

print('물음표가 있는 질문: {:.2f}%'.format(qmarks * 100))
print('마침표가 있는 질문: {:.2f}%'.format(fullstop * 100))
print('첫 글자가 대문자인 질문: {:.2f}%'.format(capital_first * 100))
print('대문자가 있는 질문: {:.2f}%'.format(capitals * 100))
print('숫자가 있는 질문: {:.2f}%'.format(numbers * 100))

# 결과
# 물음표가 있는 질문: 29.55%
# 마침표가 있는 질문: 99.69%
# 첫 글자가 대문자인 질문: 0.00%
# 대문자가 있는 질문: 99.59%
# 숫자가 있는 질문: 56.66%

데이터 전처리

  • 데이터를 모델에 적용할 수 있도록 데이터 전처리를 진행한다. 먼저 데이터 전처리 과정에서 사용할 라이브러리들을 불러올 것이다.

  • 우선 전처리를 위해 nltk의 stopword를 이용하기 위해 nltk에서 다운로드를 받아야한다. 필자는 all를 선택하여서 모든 파일을 download를 받았지만, stopword만 받아도 상관없다.

    • re, BeautifulSoup : 데이터 정제
    • stopwords : 불용어 제거
    • pad_sequence, Tokenizer : 데이터 전처리
1
2
3
4
5
6
7
8
9
10
import re
import pandas as pd
import numpy as np
import json
from bs4 import BeautifulSoup
from nltk.corpus import stopwords
from tensorflow.python.keras.preprocessing.sequence import pad_sequences
from tensorflow.python.keras.preprocessing.text import Tokenizer
import nltk
nltk.download()

nltk 다운로드

  • 리뷰 데이터를 보면 문장 사이에
    과 같은 HTML 태그와 ‘\’, ‘…’ 같은 특수문자가 포함된 것을 확인할 수 있다. 문장부호 및 특수문자는 일반적으로 문자의 의미에 크게 영향을 미치지 않기 때문에 최적화된 학습을 위해 제거하자

  • BeautifulSoup의 get_text함수를 이용하면 HTML 태그를 제거한 나머지 텍스트를 얻을 수 있고 re.sub을 이용해 특수문자를 제거한다.

  • stopwords(불용어)를 삭제할 것이다. 불용어문장에서 자주 출현하나 전체적인 의미에 큰 영향을 주지 않는 단어를 말한다. 예를들어, 영어에서는 조사, 관사 등과 같은 어휘가 있다. 데이터에 따라 불용어를 제거하는 것은 장단점이 있다. 경우에 따라 불용어가 포함된 데이터를 모델링하는 데 있어 노이즈를 줄 수 있는 요인이 될 수 있어 불용어를 제거하는 것이 좋을 수 있다. 그렇지만 데이터가 많고 문장 구문에 대한 전체적인 패턴을 모델링하고자 한다면 이는 역효과를 줄 수도 있다. 지금 시행하고자 하는 분석은 감성 분석을 하고 있으므로 불용어가 감정 판단에 영향을 주지 않는다고 가정하고 불용어를 제거한다.

  • 불용어를 제거하려면 따로 정의한 불용어 사전을 이용해야 한다. 사용자가 직접 정의할 수도 있지만 고려해야 하는 경우가 너무 많아서 보통 라이브러리에서 일반적으로 정의해놓은 불용어 사전을 이용한다. NLTK의 불용어 사전을 이용할 것이며, NLTK에서 제공하는 불용어 사전은 전부 소문자 단어로 구성돼 있기 때문에 불용어를 제거하기 위해서는 모든 단어를 소문자로 바꿔야한다.

  • review_text를 lower함수를 사용해 모두 소문자로 바꿔주었고, 이후 split 함수를 사용해 공백을 기준으로 reivew_text를 단어 리스트로 바꾼 후 불용어에 해당하지 않는 단어만 다시 모아서 리스트로 만들었다.

  • 결과를 보면 단어 리스트가 하나의 문자열로 바뀐 것을 확인할 수 있다.

  • 데이터를 한번에 처리하기 위해 위의 과정을 하나의 함수로 작성한다음에 apply로 적용시킨다. 함수의 경우 불용어 제거는 인자값으로 받아서 선택할 수 있게 하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def preprocessing(review, remove_stopwords=False):
# 불용어 제거는 옵션으로 선택

# 1. HTML 태그 제거
review_text = BeautifulSoup(review, 'html5lib').get_text()

# 2. 영어가 아닌 특수문자를 공백(" ")으로 대체
review_text = re.sub("[^a-zA-Z]", " ", review_text)

# 3. 대문자를 소문자로 바꾸고 공백 단위로 텍스트를 나눠서 리스트로 만든다.
words = review_text.lower().split()

if remove_stopwords:
# 4. 불용어 제거

# 영어 불용어 불러오기
stops = set(stopwords.words('english'))

# 불용어가 아닌 단어로 이뤄진 새로운 리스트 생성
words = [w for w in words if not w in stops]

# 5. 단어 리스트를 공백을 넣어서 하나의 글로 합친다.
clean_review = ' '.join(words)

else:
# 불용어를 제거하지 않을 때
clean_review = ' '.join(words)

return clean_review
1
2
3
train_data['clean_review']=train_data['review'].apply(lambda x : preprocessing(review=x, remove_stopwords=True))

train_data['clean_review'][0]

전처리 전후 비교

  • 우선 전처리한 데이터에서 각 단어를 인덱스로 벡터화해야 한다. 그리고 모델에 따라 입력값의 길이가 동일해야 하기 때문에 일정 길이로 자르고 부족한 부분은 특정값으로 채우는 패딩 과정을 진행해야 한다. 하지만 모델에 따라 각 review가 단어들의 인덱스로 구성된 벡터가 아닌 텍스트로 구성돼야 하는 경우도 있다. 따라서 지금까지 전처리한 데이터를 pandas의 DataFrame으로 만들어 두고 이후에 전처리 과정이 모두 끝난 후 전처리한 데이터를 저장할 때 함께 저장하게 한다.
1
2
3
4
5
6
# from tensorflow.python.keras.preprocessing.sequence import pad_sequences
# from tensorflow.python.keras.preprocessing.text import Tokenizer

tokenizer = Tokenizer()
tokenizer.fit_on_texts(train_data['clean_review'])
text_sequences = tokenizer.texts_to_sequences(train_data['clean_review'])
  • 위와 같이 사전에 등록되어진 인덱스로 각 review의 값들이 변경되었음을 확인할 수 있었다. 단어 사전은 앞서 정의한 tokenizer 객체에 word_index 값을 뽑으면 dictionary 형태로 구성되어 있음을 확인 할 수 있다. 또한 단어 사전 뿐만 아니라 전체 단어 개수도 이후 모델에서 사용되기 때문에 저장해 둔다.
1
2
3
word_vocab = tokenizer.word_index
print(word_vocab)
print("전체 단어 개수:", len(word_vocab))

단어 사전

1
2
3
4
5
data_configs = {}

data_configs['vocab'] = word_vocab

data_configs['vocab_size'] = len(word_vocab) + 1
  • 현재 각 데이터는 서로 길이가 다른데 이 길이를 하나로 통일해야 이후 모델에 바로 적용할 수 있기 때문에 특정 길이를 최대 길이로 정하고 더 긴 데이터의 경우 뒷부분을 자르고 짧은 데이터의 경우에는 0 값으로 패딩하는 작업을 진행한다.

  • 패딩 처리에는 앞서 불러온 pad_sequences 함수를 사용한다. 이 함수를 사용할 때는 인자로 패딩을 적용할 데이터, 최대 길이값, 0 값을 데이터 앞에 넣을지 뒤에 넣을 지 여부를 설정한다. 또한, 제일 마지막 단어부터 단어를 카운트한다는 것에 유의하자. 여기서 최대 길이를 174로 설정했는데, 이는 앞서 데이터 분석 과정에서 단어 개수의 통계를 계산했을 때 나왔던 중앙값(median)이다. 보통 평균이 아닌 중앙값(median)을 사용하는 경우가 많은데, 평균은 이상치에 민감하기 때문이다.

1
2
3
4
5
6
7
# 문장 최대 길이
MAX_SEQUENCE_LENGTH = 174

# padding을 뒷부분에 한다.
train_inputs = pad_sequences(text_sequences, maxlen=MAX_SEQUENCE_LENGTH, padding='post')

print('Shape of train data: ', train_inputs.shape)
  • 마지막으로 학습 시 label 값을 넘파이 배열로 저장한다. 그 이유는 이후 전처리한 데이터를 저장할 때 넘파이 형태로 저장하기 때문이다.
1
2
train_labels = np.array(train_data['sentiment'])
print('Shape of label tensor: ', train_labels.shape)
  • 이제 전처리한 데이터를 이후 모델링 과정에서 사용하기 위해 저장할 것이다. 여기서는 다음과 같은 총 4개의 데이터를 저장할 것이다. 텍스트 데이터의 경우 CSV 파일로 저장하고, 벡터화한 데이터와 정답 라벨의 경우 넘파이 파일로 저장한다. 마지막 데이터 정보의 경우 dictionary 형태이기 때문에 Json 파일로 저장한다.

    • 정제된 텍스트 데이터
    • 벡터화한 데이터
    • 정답 라벨
    • 데이터 정보
  • 우선 경로와 파일명을 설정하고 os 라이브러리를 통해 폴더가 없는 경우 폴더를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DATA_OUT_PATH = '/content/'
TRAIN_INPUT_DATA = 'train_input.npy'
TRAIN_LABEL_DATA = 'train_label.npy'
TRAIN_CLEAN_DATA = 'train_clean.csv'
DATA_CONFIGS = 'data_configs.json'

import os
# 저장하는 디렉터리가 존재하지 않으면 생성
if not os.path.exists(DATA_OUT_PATH):
os.makedirs(DATA_IN_PATH)

# 전처리된 데이터를 numpy 형태로 저장
np.save(open(DATA_IN_PATH + TRAIN_INPUT_DATA, 'wb'), train_inputs)
np.save(open(DATA_IN_PATH + TRAIN_LABEL_DATA, 'wb'), train_labels)

# 정제된 텍스트를 CSV 형태로 저장
train_data.to_csv(DATA_IN_PATH + TRAIN_CLEAN_DATA, index=False)

# 데이터 사전을 JSON 형태로 저장
json.dump(data_configs, open(DATA_IN_PATH + DATA_CONFIGS, 'w'), ensure_ascii=False)
  • 지금까지 학습 데이터에 대해서만 전처리를 했으므로 테스트 데이터에 대해서도 위와 동일한 과정을 진행하면 된다. 다른 점은 평가 데이터의 경우 라벨 값이 없기 때문에 라벨은 따로 저장하지 않아도 되며, 데이터 저옵인 단어 사전과 단어 개수에 대한 정보도 학습 데이터의 것을 사용하므로 저장하지 않아도 된다. 추가로 테스트 데이터에 대해 저장해야 하는 값이 있는데 각 review 데이터에 대해 review에 대한 'id'값을 저장해야 한다.

평가 데이터를 전처리 할 때 한 가지 중요한 점은 Tokenizer를 통해 인덱스 벡터로 만들 때 Tokenizing 객체로 새롭게 만드는 것이 아니라, 기존에 학습 데이터에 적용한 Tokenizer 객체를 사용해야 한다는 것이다. 만약 새롭게 만들 경우 학습 데이터와 평가 데이터에 대한 각 단어의들의 인덱스가 달라져서 모델에 정상적으로 적용할 수 없기 때문이다. fit_on_texts, fit_on_sequences는 사전을 업데이트하는 행위인데 아래의 코드에서 fit_on_texts를 실행하지 않는 이유는 Tokenizer 객체를 새로 생성하지 않았기에 Train data의 사전을 갖고 만약에 Train data에 포함되어 있지 않은 단어가 Test data에 존재한다면 확률을 0으로 주어야 하기 때문이다.
1
2
3
4
5
6
7
test_data = pd.read_csv(DATA_IN_PATH + 'testData.tsv', header=0, delimiter='\t', quoting=3)

test_data['review'] = test_data['review'].apply(lambda x: preprocessing(x, True))
test_id = np.array(test_data['id'])

text_sequences = tokenizer.texts_to_sequences(test_data['review'])
test_inputs = pad_sequences(text_sequences, maxlen=MAX_SEQUENCE_LENGTH, padding='post')
1
2
3
4
5
6
7
TEST_INPUT_DATA = 'test_input.npy'
TEST_CLEAN_DATA = 'test_clean.csv'
TEST_ID_DATA = 'test_id.npy'

np.save(open(DATA_IN_PATH + TEST_INPUT_DATA, 'wb'), test_inputs)
np.save(open(DATA_IN_PATH + TEST_ID_DATA, 'wb'), test_id)
test_data.to_csv(DATA_IN_PATH + TEST_CLEAN_DATA, index=False)