전처리 workflow

9 minute read

데이터

  • 네이버 영화 리뷰 데이터 ‘Naver sentiment movie corpus v1.0’
  • 출처: https://github.com/e9t/nsmc
  • 본 글에서는 평가 데이터만을 예시로 사용한다.

setup

import pandas as pd
import re
from tqdm import tqdm
from konlpy.tag import Okt
from pprint import pprint

DATA_PATH = r'C:\Users\dpelt\Downloads\nsmc-master\nsmc-master'

파일 크기 확인

import os

def get_file_size(file_name):
    size = round(os.path.getsize(DATA_PATH + '/ratings_test.txt') / 1000000, 2)
    print('file size: {} MB'.format(size))

FILE_NAME = 'ratings_test.txt'
get_file_size(FILE_NAME)
file size: 4.89 MB

파일 읽기

data = pd.read_csv(DATA_PATH + '/' + FILE_NAME, 
                   header=0,
                   delimiter='\t',
                   quoting=3)
# 텍스트 데이터만을 따로 뽑아서 사용
text_data = data['document']
text_data.head()
0                                                  굳 ㅋ
1                                 GDNTOPCLASSINTHECLUB
2               뭐야 이 평점들은.... 나쁘진 않지만 10점 짜리는 더더욱 아니잖아
3                     지루하지는 않은데 완전 막장임... 돈주고 보기에는....
4    3D만 아니었어도 별 다섯 개 줬을텐데.. 왜 3D로 나와서 제 심기를 불편하게 하죠??
Name: document, dtype: object

만약 텍스트 데이터가 html 형식으로 구성되어 있다면, BeautifulSoup을 이용하여 html 문법에 해당하는 문자들을 모두 제거하고 본격적인 전처리에 돌입하는 것이 좋다.

  • BeautifulSoup에 대한 사용법은 다른 게시글에서 자세히 다루겠습니다!

가장 중요한 규칙

단어 사전에 포함되는 단어의 개수를 최소화!

단어의 개수가 많아질수록 문서 전체에 대한 단어 사전의 크기가 매우 커지게 되는데, 이는 다뤄야 하는 데이터의 크기가 매우 커지는 것을 의미한다. 따라서 학습의 속도가 느려지고 분석 결과 확인에서의 어려움이 있다.

정제 → 품사 태깅을 통한 정규화

품사 기반의 corpus 재구성 방식을 사용!

1-1. 한글화 정제

일반적으로 한글의 텍스트 마이닝을 할 때, 영어와 같은 경우에는 단어 사전에 포함시켜야 하는 유의미한 단어인 경우가 매우 드물다. 즉, 특수한 전문 용어 이외에는 텍스트 분류나 유사도와 같은 분석 결과에 영향을 거의 미치지 않는다(숫자와 특수문자는 당연히 제외되어야 한다).

특히나, 예를 들면 감성 분석과 같은 텍스트 마이닝 과제를 통해 한글 감성 사전을 구축한다고 가정해보자. 이러한 경우에 ‘USA’, ‘15.5%’, 그리고 ‘10.2 points’와 같은 단어들은 감성이 담겨있지 않은 단어들이다. 따라서 이러한 단어들은 데이터에서 제외시켜야 할 필요가 있다.

다음은 문장에서 한글만을 추출하는 함수이다. 만약 입력된 sent가 문자열이 아니라면 빈 문자열을 반환하게 된다.

def clean_korean(sent):
    if type(sent) == str:
        h = re.compile('[^가-힣ㄱ-ㅎㅏ-ㅣ\\s]+')
        result = h.sub('', sent)
    else:
        result = ''
    return result
clean_korean(text_data.iloc[2])
'뭐야 이 평점들은 나쁘진 않지만 점 짜리는 더더욱 아니잖아'

원래의 데이터와 비교해보면 .과 10점의 10이 제거된 것을 볼 수 있다.

모든 데이터에 대해 위의 함수를 적용한다.

clean_text = []
for i in tqdm(range(len(text_data))):
    sent = clean_korean(text_data.iloc[i])
    if len(sent) > 0: # 비어있는 데이터가 아닌지 확인
        clean_text.append(sent)
100%|██████████| 50000/50000 [00:00<00:00, 67248.44it/s]

한글화된 결과를 확인하자.

pprint(clean_text[:6])
['굳 ㅋ',
 '뭐야 이 평점들은 나쁘진 않지만 점 짜리는 더더욱 아니잖아',
 '지루하지는 않은데 완전 막장임 돈주고 보기에는',
 '만 아니었어도 별 다섯 개 줬을텐데 왜 로 나와서 제 심기를 불편하게 하죠',
 '음악이 주가 된 최고의 음악영화',
 '진정한 쓰레기']

1-2. 불용어 제거를 통한 정제

사전에 필요 없는 단어의 목록을 안다면, 이러한 단어들을 corpus에서 직접 제거하는 것도 정제화의 한가지 방법이다.

예를 들어, 한글 기사 데이터를 이용해 감성 분석을 진행한다면, ‘기자, 신문, 일보, 뉴스’와 같은 단어는 감성 분석에 있어 불필요한 단어들일 가능성이 높다. 따라서 이러한 단어들을 corpus에서 미리 제거하는 정제화도 가능하다.

2. 품사 태깅을 통한 정규화

품사 태깅

KoNLPy 라이브러리의 Okt를 이용하여 품사 태깅을 한다. 여기서는 pos method를 이용하여 tokenized된 단어들의 품사 또한 얻는다.

okt = Okt()

okt 객체를 불러오는 데도 시간이 소요되므로 단 한번만 할당한다.

print(okt.pos(clean_text[4], stem=True))
[('음악', 'Noun'), ('이', 'Josa'), ('주가', 'Noun'), ('되다', 'Verb'), ('최고', 'Noun'), ('의', 'Josa'), ('음악', 'Noun'), ('영화', 'Noun')]

이때 stem=True로 설정하여 정규화를 했을 때의 장점이 있다.

  • 단어의 어간을 추출하므로 의미는 동일하지만 표기가 다른 단어들에 대해 동일한 어간을 갖는 하나의 단어로 통합해준다. 따라서 전체 단어 사전의 크기를 줄일 수 있다.
  • 동일한 단어를 여러개의 다른 단어로 별도 학습하는 것이 아닌, 하나의 단어(feature)로 통일하여 풍부한 학습이 가능해진다.

시간 상 10000개의 sample에 대해서만 품사 태깅을 한다.

clean_okt_text = [okt.pos(x, stem=True) for x in clean_text[:10000]]

전체 데이터에 대해서는 다음과 같이 가능하다.

clean_okt_text = []
for i in tqdm(range(len(clean_text))):
    sent = okt.pos(clean_text[i], stem=True)
    clean_okt_text.append(sent)

품사 태깅의 결과를 확인해보자.

pprint(clean_okt_text[:6])
[[('굳다', 'Adjective'), ('ㅋ', 'KoreanParticle')],
 [('뭐', 'Noun'),
  ('야', 'Josa'),
  ('이', 'Noun'),
  ('평점', 'Noun'),
  ('들', 'Suffix'),
  ('은', 'Josa'),
  ('나쁘다', 'Adjective'),
  ('않다', 'Verb'),
  ('점', 'Noun'),
  ('짜다', 'Verb'),
  ('리', 'Noun'),
  ('는', 'Josa'),
  ('더', 'Noun'),
  ('더욱', 'Noun'),
  ('아니다', 'Adjective')],
 [('지루하다', 'Adjective'),
  ('않다', 'Verb'),
  ('완전', 'Noun'),
  ('막장', 'Noun'),
  ('임', 'Noun'),
  ('돈', 'Noun'),
  ('주다', 'Verb'),
  ('보기', 'Noun'),
  ('에는', 'Josa')],
 [('만', 'Noun'),
  ('아니다', 'Adjective'),
  ('별', 'Noun'),
  ('다섯', 'Noun'),
  ('개', 'Noun'),
  ('주다', 'Verb'),
  ('왜', 'Noun'),
  ('로', 'Noun'),
  ('나오다', 'Verb'),
  ('제', 'Noun'),
  ('심기', 'Noun'),
  ('를', 'Josa'),
  ('불편하다', 'Adjective'),
  ('하다', 'Verb')],
 [('음악', 'Noun'),
  ('이', 'Josa'),
  ('주가', 'Noun'),
  ('되다', 'Verb'),
  ('최고', 'Noun'),
  ('의', 'Josa'),
  ('음악', 'Noun'),
  ('영화', 'Noun')],
 [('진정하다', 'Adjective'), ('쓰레기', 'Noun')]]

유의미한 품사 추출 - 품사 기반의 corpus 재구성

사실 한글에서는 유의미한 의미를 갖는 품사들이 매우 제한적이다. 일반적으로, 명사, 동사, 형용사등이 가장 중요한 의미를 담는 경우가 많다. 앞의 결과 예시에서 나온 ('이', 'Josa')와 같은 경우에는 의미를 담고 있지 않다. 따라서 이러한 불필요한 품사들은 제거하는 것이 전체 단어 사전의 크기 축소분석 결과 해석의 용이, 이 2가지 측면에서 매우 유용하다!

하지만 유의미한 품사를 추출하는 데도 주의해야 할 점이 있다. ('ㅋ', 'KoreanParticle')와 같은 경우에는 품사가 명사, 동사, 형용사가 아니지만 그 의미가 유의미하지 않다고 단정하기에는 매우 난감하다. 따라서 전처리를 할 때 특정 품사만을 선택하여 corpus를 재구성하고자 한다면, 어떠한 품사들이 현재 분석에서 필요한지 신중하게 선택하는 것이 필요할 것이다.

다음은 명사, 동사, 형용사만을 추출하여 corpus를 재구성하는 코드이다.

useful_tag = ('Noun', 'Verb', 'Adjective')
clean_okt_useful_text = []
for i in tqdm(range(len(clean_okt_text))):
    sent = [x for x in clean_okt_text[i] if x[1] in useful_tag]
    clean_okt_useful_text.append(sent)
pprint(clean_okt_useful_text[:6])
[[('굳다', 'Adjective')],
 [('뭐', 'Noun'),
  ('이', 'Noun'),
  ('평점', 'Noun'),
  ('나쁘다', 'Adjective'),
  ('않다', 'Verb'),
  ('점', 'Noun'),
  ('짜다', 'Verb'),
  ('리', 'Noun'),
  ('더', 'Noun'),
  ('더욱', 'Noun'),
  ('아니다', 'Adjective')],
 [('지루하다', 'Adjective'),
  ('않다', 'Verb'),
  ('완전', 'Noun'),
  ('막장', 'Noun'),
  ('임', 'Noun'),
  ('돈', 'Noun'),
  ('주다', 'Verb'),
  ('보기', 'Noun')],
 [('만', 'Noun'),
  ('아니다', 'Adjective'),
  ('별', 'Noun'),
  ('다섯', 'Noun'),
  ('개', 'Noun'),
  ('주다', 'Verb'),
  ('왜', 'Noun'),
  ('로', 'Noun'),
  ('나오다', 'Verb'),
  ('제', 'Noun'),
  ('심기', 'Noun'),
  ('불편하다', 'Adjective'),
  ('하다', 'Verb')],
 [('음악', 'Noun'),
  ('주가', 'Noun'),
  ('되다', 'Verb'),
  ('최고', 'Noun'),
  ('음악', 'Noun'),
  ('영화', 'Noun')],
 [('진정하다', 'Adjective'), ('쓰레기', 'Noun')]]

이제 불필요한 tag는 지우고 단어들만으로 이루어진 corpus를 다시 재구성한다.

preprocessed_text = []
for i in tqdm(range(len(clean_okt_useful_text))):
    sent = [x[0] for x in clean_okt_useful_text[i]]
    preprocessed_text.append(sent)
pprint(preprocessed_text[:6])
[['굳다'],
 ['뭐', '이', '평점', '나쁘다', '않다', '점', '짜다', '리', '더', '더욱', '아니다'],
 ['지루하다', '않다', '완전', '막장', '임', '돈', '주다', '보기'],
 ['만', '아니다', '별', '다섯', '개', '주다', '왜', '로', '나오다', '제', '심기', '불편하다', '하다'],
 ['음악', '주가', '되다', '최고', '음악', '영화'],
 ['진정하다', '쓰레기']]

indexing and padding

1. indexing

최종 전처리된 데이터에 대해서 각 단어 별로 index를 부여한다(이는 단어의 embedding 과정에서 사용 목적).

from tensorflow.keras import preprocessing
tokenizer = preprocessing.text.Tokenizer()
tokenizer.fit_on_texts(preprocessed_text)
sequences = tokenizer.texts_to_sequences(preprocessed_text)
vocab = tokenizer.word_index
pprint(sequences[:6])
[[415],
 [48, 15, 19, 265, 13, 12, 232, 564, 28, 753, 16],
 [42, 13, 60, 294, 79, 75, 38, 99],
 [170, 16, 184, 3708, 91, 38, 24, 320, 18, 247, 5451, 635, 2],
 [256, 1976, 10, 21, 256, 1],
 [540, 62]]
pprint(list(vocab.items())[:6])
print('전체 단어 개수: {}'.format(len(vocab)))
[('영화', 1), ('하다', 2), ('보다', 3), ('없다', 4), ('있다', 5), ('좋다', 6)]
전체 단어 개수: 11569

이때, 단어의 index가 1부터 시작하므로 후에 단어 embedding matrix를 구축하기 위해 전체 단어 개수를 저장해 놓을 때, len(vocab)+1로 1을 더해서 저장해야 한다. 왜냐하면 후에 embedding matrix에서 단어의 embedding vector를 lookup하는 과정에서 각 단어의 index를 사용하게 되므로 전체 단어 개수에서 1을 더해야 모든 단어들이 error가 발생하지 않고 lookup이 가능하다.

예를 들어, 다음을 보자.

pprint(list(vocab.items())[11568])
('냉무', 11569)

전체 단어 사전의 마지막 단어인 ‘냉무’는 만약 embedding matrix가 (len(vocab), embedding_size)이라면 ‘냉무’의 index인 11569으로 lookup을 하려고 한다면 error가 발생할 것이다(python의 indexing은 0부터 시작).

2. padding

모든 sample의 shape을 통일해주기 위해, padding 작업을 한다.

MAX_PAD_LENGTH = max([len(x) for x in sequences])
print('sequence의 최대 길이: {}'.format(MAX_PAD_LENGTH))
inputs = preprocessing.sequence.pad_sequences(sequences,
                                              maxlen=MAX_PAD_LENGTH,
                                              padding='post')
sequence의 최대 길이: 47

여기서는 sequence의 최대 길이를 이용해 padding을 하였지만, 일반적으로 중간값을 사용한다. 하지만 중간값보다 sequence sample의 길이가 길다면, maxlen에 해당하는 개수만큼의 index만 inputs에 남을 것이다.

pprint(inputs[:6])
array([[ 415,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0],
       [  48,   15,   19,  265,   13,   12,  232,  564,   28,  753,   16,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0],
       [  42,   13,   60,  294,   79,   75,   38,   99,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0],
       [ 170,   16,  184, 3708,   91,   38,   24,  320,   18,  247, 5451,
         635,    2,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0],
       [ 256, 1976,   10,   21,  256,    1,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0],
       [ 540,   62,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0]])
print('inputs의 shape: {}'.format(inputs.shape))
inputs의 shape: (10000, 47)

inputs의 sample의 shape이 (47, )으로 모두 통일된 것을 볼 수 있다.

또한 padding에서 추가된 index 0이 앞의 len(vocab)+1로 저장된 전체 단어의 개수에서 0번 index가 되므로 (len(vocab)+1, embedding_size)shape의 embedding matrix에서 첫 번째 행에 해당한다.

이제 inputs를 이용해 training을 진행하면 된다!

한글화 정제의 단점

1. 단점

한글화 정제를 진행하게 되면, 자칫 유의미한 단어가 제외되는 불상사가 생긴다.

print(text_data[4])
3D만 아니었어도 별 다섯 개 줬을텐데.. 왜 3D로 나와서 제 심기를 불편하게 하죠??

위의 문장을 보자. ‘3D’와 같은 경우에는 숫자와 영어로 되어 있지만, 영화 리뷰라는 데이터의 특성상 유의미한 단어로 분류하는 것이 더 바람직할 것이다.

하지만 한글 정제 후의 결과를 보자.

print(preprocessed_text[3])
['만', '아니다', '별', '다섯', '개', '주다', '왜', '로', '나오다', '제', '심기', '불편하다', '하다']

최종 정제된 후의 데이터를 보면 ‘3D’가 제외되어 있는 것을 볼 수 있다. 따라서 이렇게 한글 정제를 하는 경우에는 혹시 숫자나 영어로 구성 된 단어가 유의미하게 사용될 수 있는지 면밀히 확인할 필요성이 있다.

2. 한글 정제화를 생략한 품사 태깅

한글화 정제화를 통해 만약 많은 필요한 단어들이 제외될 것으로 예상된다면, 한글 정제화 과정이 없이 품사 태깅을 하는 것도 하나의 방법이다.

okt_text = []
for i in range(1000):
    if type(text_data[i]) == str:
        sent = okt.pos(text_data[i], stem=True)
        okt_text.append(sent)
pprint(okt_text[:6])
[[('굳다', 'Adjective'), ('ㅋ', 'KoreanParticle')],
 [('GDNTOPCLASSINTHECLUB', 'Alpha')],
 [('뭐', 'Noun'),
  ('야', 'Josa'),
  ('이', 'Noun'),
  ('평점', 'Noun'),
  ('들', 'Suffix'),
  ('은', 'Josa'),
  ('....', 'Punctuation'),
  ('나쁘다', 'Adjective'),
  ('않다', 'Verb'),
  ('10', 'Number'),
  ('점', 'Noun'),
  ('짜다', 'Verb'),
  ('리', 'Noun'),
  ('는', 'Josa'),
  ('더', 'Noun'),
  ('더욱', 'Noun'),
  ('아니다', 'Adjective')],
 [('지루하다', 'Adjective'),
  ('않다', 'Verb'),
  ('완전', 'Noun'),
  ('막장', 'Noun'),
  ('임', 'Noun'),
  ('...', 'Punctuation'),
  ('돈', 'Noun'),
  ('주다', 'Verb'),
  ('보기', 'Noun'),
  ('에는', 'Josa'),
  ('....', 'Punctuation')],
 [('3', 'Number'),
  ('D', 'Alpha'),
  ('만', 'Noun'),
  ('아니다', 'Adjective'),
  ('별', 'Noun'),
  ('다섯', 'Noun'),
  ('개', 'Noun'),
  ('주다', 'Verb'),
  ('..', 'Punctuation'),
  ('왜', 'Noun'),
  ('3', 'Number'),
  ('D', 'Alpha'),
  ('로', 'Noun'),
  ('나오다', 'Verb'),
  ('제', 'Noun'),
  ('심기', 'Noun'),
  ('를', 'Josa'),
  ('불편하다', 'Adjective'),
  ('하다', 'Verb'),
  ('??', 'Punctuation')],
 [('음악', 'Noun'),
  ('이', 'Josa'),
  ('주가', 'Noun'),
  ('되다', 'Verb'),
  (',', 'Punctuation'),
  ('최고', 'Noun'),
  ('의', 'Josa'),
  ('음악', 'Noun'),
  ('영화', 'Noun')]]

품사 태깅의 결과를 보면 한글 정제화 단계에서 제외되었던 ('GDNTOPCLASSINTHECLUB', 'Alpha'), ('...', 'Punctuation'), ('3', 'Number'), ('D', 'Alpha') 등등이 추가된 것을 볼 수 있다. 이는 Okt() 자체적인 품사 태깅 기능에 의존하여 tokenizing한 것으로, 한글 정제화를 한 결과보다 풍부한 단어나 품사의 선택이 가능하다는 장점이 있다.

Comments