0. Introduction
본 시리즈는 텍스트 마이닝은 전공 수업 때 간단하게 들은게 전부인 예비 대학원생이 챗봇 과제에 투입되어 고군분투하는 이야기를 담았습니다. 틀린 부분을 알려주시거나 더 자세하게 다루었으면 하는 내용, 그리고 제가 생각하지 못한 새로운 의견은 언제나 환영합니다.
본 시리즈는 빠르게 NLP 실전 프로젝트에 참여할 수 있도록 개념을 빠르게 잡아주는 것을 목표로 하며, 이를 위해 필요한 지식과 실무에 바로 사용할 수 있을 정도의 예제 코드를 제공하고자 합니다.
목차는 다음과 같습니다.
- NLP에서의 전처리 방법 (상/하)
- NLP에서 사용하는 모델과 방법론
- 결과 분석 및 성능 향상
1. NLP에서의 전처리 방법
자연어 데이터를 처음 다루어 본다면, 우리가 흔히 보던 데이터의 모습과 많이 다른 형태를 가지고 있다고 많이 느끼실 것입니다. 왜냐하면 우리가 기존에 많이 보아왔던 아이리스 데이터나 보스턴 집값 데이터에서는 행과 열로 이루어진 테이블 형식으로 데이터가 이루어져 있고, '꽃잎 길이'나 '방의 개수'와 같이 하나의 열이 직관적인 의미를 가져다 주지만 이와는 다르게, 텍스트 데이터는 어떻게 특징을 나타낼 것이며 우리의 모델에 어떻게 인풋으로 넣어야 할 지 바로 떠올리기 어렵기 때문이죠.
그럼 여기서 우리는 텍스트에서 어떻게 의미를 찾아낼 수 있으며 이를 어떻게 나타낼 수 있을까요?
쉽게 생각해보면, 텍스트를 정형화 된 데이터 처럼 만들 수 있다면 우리가 쉽게 모델에 넣고 학습시킬 수 있을 것입니다. 그렇게 된다면 전체 텍스트 데이터는 매트릭스로 나타나고, 각각의 문장은 인스턴스가 되어 벡터 형태로 표현할 수 있어 보입니다.
그럼 바로 텍스트를 정형 데이터로 나타내기 전에 이해에 필요한 개념들에 대해서 간략하게 설명하겠습니다.
2. 전처리 기초 개념
- 본 글은 nltk(Nature Language Toolkit) 라이브러리를 이용하여 전처리 예시 코드를 작성했습니다.
- 본 글에서 사용하는 텍스트 데이터는 Raw 데이터인 HTML 혹은 JSON 형태에서 일차적으로 데이터를 추출 및 정제했다고 가정합니다. 즉, 온전한 string 형태의 데이터만을 취급합니다.
- 본 글의 예시에서는 List Comprehension, 객체지향 개념이 일부 나오기 때문에 독자가 파이썬을 어느정도 활용할 줄 안다고 가정합니다.
1) tokenization (토큰화)
-
분석하고자 하는 텍스트를 단어, 문자, n-gram 등 특정 단위로 나누는 작업을 의미하며, 자연어 처리를 하기 위해 가장 기본적으로 수행되어야 하는 작업입니다.
-
일반적으로 단어 단위로 토큰화를 진행하며, 라이브러리 별로 토큰화 하는 규칙은 다르기 때문에 같은 문장을 토큰화 하더라도 다른 결과가 나올 수 있습니다.
from nltk.tokenize import word_tokenize
#sample text
text1 = "I Love You."
text2 = "I can't stop!"
word_tokenize(text1) # ['I', 'Love', 'You', '.']
word_tokenize(text2) # ['I', 'ca', "n't", 'stop', '!']
2) stopword (불용어)
-
관사, 전치사 등 실질적으로 의미를 가지지 않는 단어들을 의미합니다.
-
불용어가 많으면 별다른 의미를 가지지 않는 단어들이 모델을 복잡하게 만들기 때문에, 성능과 연산속도를 높이기 위해 제거합니다.
from nltk.corpus import stopwords
#영어 불용어를 호출합니다.
stopwords.words('english') # ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', "you're",...]
3) stemming / lemmatization
어간 추출과 표제어 추출은 코퍼스, 즉 말뭉치에서 중복된 의미를 가지는 단어를 하나로 통합하기 위한 방법입니다. 일반적으로 단어의 빈도를 기반으로 문장의 의미를 추론하는 Bag of Word(BOW) 모델에서 사용되어집니다.
그럼 왜 중복된 단어를 전처리 단계에서 제거하고자 할까요?
중복된 의미를 가지는 단어를 통합하는 이유는 간단합니다. 컴퓨터는 의미가 같아도 글자가 한 글자라도 다르다면 다른 개체로 인식하기 때문이죠. 사람은 'love'와 'loves'를 들었을 때 동일한 의미를 가진다는 것을 알기 때문에 어렵게 느끼지 않지만, 컴퓨터는 그렇지 못하기 때문에 loves, love, is, are, be 등 같은 의미를 가지지만 다르게 표현되는 단어들을 입력받을 때 마다 모델이 매우 복잡해집니다.
따라서 Stemming이나 Lemmatization같은 전처리 기법들을 사용하여 같은 의미를 지니는 단어를 하나로 통합하고, 일반화 함으로써 복잡도를 낮추는 역할을 합니다. 두 방법은 말로 설명하는 것 보다 예시를 보는 것이 이해가 더 빠를 것 같습니다.
- Stemming (어간 추출)
단어의 뜻을 잃지 않는 선에서 단어의 앞부분을 추출합니다. 유명한 방법으로 Porter 알고리즘이 있습니다.
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer
text = "A processing interface for removing morphological affixes from words. This process is known as stemming."
tokens = word_tokenize(text)
pst = PorterStemmer() # poter 방법 stemming 인스턴스를 생성한다.
stemmed_tokens = [pst.stem(token) for token in tokens]
print(tokens) # ['A', 'processing', 'interface', 'for', 'removing', 'morphological', 'affixes', 'from', 'words', '.', 'This', 'process', 'is', 'known', 'as', 'stemming', '.']
print(stemmed_tokens) # ['A', 'process', 'interfac', 'for', 'remov', 'morpholog', 'affix', 'from', 'word', '.', 'thi', 'process', 'is', 'known', 'as', 'stem', '.']
- Lemmatization (표제어 추출)
단어의 기본형으로 변환해 줍니다.
from nltk.stem import WordNetLemmatizer
text = "A processing interface for removing morphological affixes from words. This process is known as stemming."
tokens = word_tokenize(text)
wnl = WordNetLemmatizer() # WordNet 방법 lemmatization 인스턴스를 생성한다.
lamm_tokens = [wnl.lemmatize(token) for token in tokens]
print(tokens) # ['A', 'processing', 'interface', 'for', 'removing', 'morphological', 'affixes', 'from', 'words', '.', 'This', 'process', 'is', 'known', 'as', 'stemming', '.']
print(lamm_tokens) # ['A', 'processing', 'interface', 'for', 'removing', 'morphological', 'affix', 'from', 'word', '.', 'This', 'process', 'is', 'known', 'a', 'stemming', '.']
두 방법의 차이가 느껴지시나요? Stemming은 어근이라고 불리는 의미를 가지는 부분만 남겨둔 채로 나머지 부분을 제거해줍니다. 예를들면 위의 코드에서 'interface'을 실제 의미를 지니는 'interfac'만 남겨놓고 제거하는 것을 확인할 수 있습니다. 반면에 Lemmatization은 단어를 원형으로 바꾸어 줍니다. 위의 예시에서 'interface'는 표제어 추출 이후에도 원형 그대로를 유지하고 있습니다.
참고로 두 방법의 성능은 비슷한 것으로 알려져 있으며, Stemming이 단순 규칙 기반이기 때문에 수행 속도가 조금 더 빠르다고 합니다.
4) 대문자/소문자 통합
위에서 언급한 Stemming과 Lammatization과 마찬가지로 중복된 의미의 단어를 하나로 통합하여 데이터 전체의 복잡성을 낮추기 위해서 사용합니다. 컴퓨터는 'LOVE'와 'love'를 다른 단어로 취급하기 때문에 모든 단어를 소문자 혹은 대문자로 변환합니다.
text = "I Love You."
text_lower = text.lower() # 소문자로 변환합니다.
text_upper = text.upper() # 대문자로 변환합니다.
3. 전처리 실습
그럼 위에서 다루어 본 내용을 바탕으로 간단하게 전처리를 하는 예시 코드를 작성해 보겠습니다.
전처리는 다음과 같은 시나리오로 진행됩니다.
-
Tokenization
-
Stopword 제거
-
Stemming & 소문자 변환
본 글에서는 이해를 돕기 위해 단계별로 전처리 코드를 작성했지만 실제 코드에서는 축약해서 한 반복문 안에서 처리 하기도 합니다.
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer
text1 = "I Love You."
text2 = "I can't stop!"
text3 = "A processing interface for removing morphological affixes from words. This process is known as stemming."
# 데이터셋에서 X에 해당합니다.
texts = [text1, text2, text3]
# nltk에서 영어 불용어를 불러온다.
stop_words = set(stopwords.words('english'))
# 어간추출을 위한 인스턴스를 만든다.
pst = PorterStemmer()
# 1) Tokenization
tokens = [word_tokenize(txt) for txt in texts] # text1, text2, text3를 각각 소문자로 변환하고 word_tokenize()함수에 넣어 토큰화된 결과값을 리스트에 담는다.
# 2) stopword 제거
tokens_wo_stopword = [[word for word in token if not word in stop_words] for token in tokens]
# 3) stemming & 소문자 변환
tokens_stemmed = [[pst.stem(word) for word in token] for token in tokens_wo_stopword]
# 전처리 결과
print(tokens)
print(tokens_wo_stopword)
print(tokens_stemmed)
# [['I', 'Love', 'You', '.'], ['I', 'ca', "n't", 'stop', '!'], ['A', 'processing', 'interface', 'for', 'removing', 'morphological', 'affixes', 'from', 'words', '.', 'This', 'process', 'is', 'known', 'as', 'stemming', '.']]
# [['I', 'Love', 'You', '.'], ['I', 'ca', "n't", 'stop', '!'], ['A', 'processing', 'interface', 'removing', 'morphological', 'affixes', 'words', '.', 'This', 'process', 'known', 'stemming', '.']]
# [['I', 'love', 'you', '.'], ['I', 'ca', "n't", 'stop', '!'], ['A', 'process', 'interfac', 'remov', 'morpholog', 'affix', 'word', '.', 'thi', 'process', 'known', 'stem', '.']]
각 전처리 단계에서의 차이가 느껴지시나요? tokenization 이후에 stopword를 제거하니 for이나 as, is 등의 단어들이 제거되었고 stemming을 하니 첫 글자를 제외한 나머지 단어들이 소문자로 바뀌면서 대문자를 통해 문장의 시작을 나타냄을 확인할 수 있습니다. 따라서 소문자나 대문자로 변환은 stemming을 통해 자동으로 진행되므로 예제 코드와 같이 텍스트에 lower()나 upper()를 적용할 필요가 없습니다. (적용해도 stemming 과정에서 문장의 첫 글자는 대문자로 바뀝니다.)
아래는 조금 더 실무적으로 활용해 볼 수 있는 코드입니다. For문을 돌면서 각 문장의 전처리 과정을 반복문 안에서 모두 처리해 주어 조금 더 깔끔하게 작성해 보았습니다.
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer
text1 = "I Love You."
text2 = "I can't stop!"
text3 = "A processing interface for removing morphological affixes from words. This process is known as stemming."
# 데이터셋에서 X에 해당합니다.
texts = [text1, text2, text3]
# nltk에서 영어 불용어를 불러온다.
stop_words = set(stopwords.words('english'))
# 어간추출을 위한 인스턴스를 만든다.
pst = PorterStemmer()
# 토큰화 및 불용어 제거
tokens = []
for txt in texts:
token = word_tokenize(txt) # i love korea -> 'i', 'love', 'korea'로 나누어 준다.
non_stopwords = [pst.stem(t) for t in token if not t in stop_words] # 불용어가 아닌 token을 어간추출해서 리스트에 넣는다.
tokens.append(non_stopwords)
# 전처리 결과
print(tokens)
여기까지가 텍스트의 전처리 내용입니다. 혹시나 이해가 되지 않은 내용이나 추가할 내용이 있다면 댓글로 남겨주시면 확인하는 대로 답변 남기도록 하겠습니다.
감사합니다.