Note 421 자연어처리, 텍스트 전처리, 등장 횟수 기반의 단어표현
자연어는 사람들이 일상적으로 쓰는 언어를 의미하고, 인공적으로 만들어진 언어(프로그래밍 언어 등)와 구분하여 부른다. 이 자연어를 컴퓨터로 처리하는 기술을 자연어 처리(Natural Language Processing, NLP)라고 하는데 이번 스프린트에서는 이 NLP에 대해 배웠다.
NLP로 할 수 있는 일들
1) 자연어 이해(NLU, Natural Language Understanding)
-
분류(Classification): 뉴스 기사 분류, 감성 분석
-
자연어 추론(NLI, Natural Language Inference): 전제와 가설을 통해 참 거짓을 판별
-
기계 독해(MRC, Machine Reading Comprehension), 질의응답(Question&Answering): 비문학 문제 풀기
-
품사 태깅(POS tagging, 문장을 형태소 단위로 분리하고 품사를 붙인다.), 개체명 인식(Named Entity Recognition, 단어가 어떤 유형인지 인식(사람 or 장소 or 시간 등)
2) 자연어 생성(NLG, Natural Language Generation)
- 텍스트 생성(뉴스 기사 생성, 가사 생성 등)
3) NLU & NLG
-
요약(Summerization): 추출 요약(Extractive summerization, 문서 내에서 해당 문서를 가장 잘 요약하는 부분 찾는 것, NLU에 가까움), 생성 요약(abstractive summerization, 해당 문서를 요약하는 요약문을 생성, NLG에 가까움)
-
챗봇
이외에도 여러 가지 예시가 있다.
자연어 처리에서 등장하는 용어들
-
말뭉치(Corpus) : 특정한 목적을 가지고 수집한 텍스트 데이터
-
문서(Document) : 문장(Sentence)들의 집합
-
문장(Sentence) : 여러 개의 토큰(단어, 형태소 등)으로 구성된 문자열. 마침표, 느낌표 등의 기호로 구분
-
어휘집합(Vocabulary) : 코퍼스에 있는 모든 문서, 문장을 토큰화한 후 중복을 제거한 토큰의 집합
-
벡터화(Vectorize): 컴퓨터가 이해할 수 있도록 자연어를 벡터로 만들어주는 작업. 즉, 텍스트를 컴퓨터가 계산할 수 있도록 수치적인 정보로 변환하는 작업이다. 등장 횟수 기반의 단어표현(Count-based Representation, Bag-of-Words, TF-IDF)과 타겟 단어의 주변 단어를 기반으로 벡터화 하는 분포 기반의 단어 표현(Distributed Representation, ex) Word2Vec, GloVe, fastText)이 있음
텍스트 전처리(Text Preprocessing)
NLP 뿐만 아니라 데이터를 학습시킬 때 학습 시킬 데이터의 관측치보다 차원의 크기(변수의 수)가 더 크다면 학습이 잘 되지 않는 것을 차원의 저주(The curse of dimensinality)라고 한다. 특히 NLP에서는 10개의 문서가 있다고 가정하고, 하나의 문서가 행, 그 문서의 단어들이 열이 된다고 해보자. 문서는 10개 밖에 없는데 만약 사용되는 단어가 100개라면 100차원으로 데이터가 표현될 것이고, 차원의 저주 개념에 의해 이것은 잘 학습되지 않을 것이다. NLP에서는 이러한 문제를 해결하기 위해 텍스트 데이터를 잘 전처리 함으로써 차원을 줄여야 한다.
그렇다면 어떻게 하면 차원을 줄일 수 있을까?
1) 대소문자 통일
대소문자가 달라서 다른 단어로 분류되어 있으면, 이를 모두 대,소문자 중 하나의 방법으로 통일 시킬 수 있다. 주로 소문자로 통일시키는 방법을 사용한다.
df['words'] = df['words'].apply(lambda x: x.lower())
2) 정규표현식 사용(Regex)
구두점, 특수문자 등과 같이 필요없는 문자를 말뭉치에서 제거해 토큰화가 더 잘 되도록 할 수 있다.
import re
def tokenize(text):
tokens = re.sub(r"[^a-zA-Z0-9 ]", "", text) # 영어 소문자, 대문자, 숫자, 공백을 제외한 모든 문자 제거
tokens = tokens.lower().split() # 소문자로 변환
return tokens
df['words'] = df['words'].apply(tokenize)
3) SpaCy를 사용한 전처리
import spacy
from spacy.tokenizer import Tokenizer
nlp = spacy.load("en_core_web_sm")
tokenizer = Tokenizer(nlp.vocab) # 이 단어들을 기반으로 토큰화 할 것이다.
from collections import Counter
def word_count(docs):
# 코퍼스 내에서 단어빈도
word_counts = Counter()
# 단어가 존재하는 문서 갯수, 단어가 존재하면 +1
word_in_docs = Counter()
# 문서 갯수
total_docs = len(docs)
for doc in docs:
word_counts.update(doc)
word_in_docs.update(set(doc))
temp = zip(word_counts.keys(), word_counts.values())
wc = pd.DataFrame(temp, columns = ['word', 'count'])
# 단어순위
# method='first': 순위가 같으면 먼저나온 단어를 우선으로
wc['rank'] = wc['count'].rank(method='first', ascending=False)
total = wc['count'].sum()
# 코퍼스 내 단어 비율
wc['percent'] = wc['count'].apply(lambda x: x / total)
wc = wc.sort_values(by='rank')
# 단어 빈도의 누적 비율
# cumsum()은 누적합
wc['cul_percent'] = wc['percent'].cumsum()
temp2 = zip(word_in_docs.keys(), word_in_docs.values())
ac = pd.DataFrame(temp2, columns=['word', 'word_in_docs'])
wc = ac.merge(wc, on='word')
# 전체 문서 중에 해당 단어가 들어있는 문서 비율
wc['word_in_docs_percent'] = wc['word_in_docs'].apply(lambda x: x / total_docs)
return wc.sort_values(by='rank')
# 토큰화를 위한 파이프라인을 구성
tokens = []
for doc in tokenizer.pipe(df['text']):
doc_tokens = [re.sub(r"[^a-z0-9]", "", token.text.lower()) for token in doc]
tokens.append(doc_tokens)
df['tokens'] = tokens
wc = word_count(df['tokens']) # 단어 빈도 확인
wc_top20 = wc[wc['rank'] <= 20] # 빈도 상위 20개 단어
squarify.plot(sizes=wc_top20['percent'], label=wc_top20['word'], alpha=0.6 ) # 상위 20개 단어들을 시각화로 표현
plt.axis('off')
plt.show()
4) 불용어 처리(Stop words)
‘the’, ‘and’, ‘of’ 처럼 너무나 많이 혹은 적게 등장하고, 큰 의미를 가지지 않는 단어를 불용어라고 한다. 접속사, 관사, 부사, 대명사 등이 일반적인 불용어이고 대부분의 NLP 라이브러리가 이를 내장하고 있다. 불용어를 추가 및 제거 할 수도 있다.
STOP_WORDS = nlp.Defaults.stop_words.union(['it.', 'the', 'this'])
tokens = []
for doc in tokenizer.pipe(df['text']):
doc_tokens = []
for token in doc:
# 토큰이 불용어와 구두점이 아니면 저장하고, 소문화 시킨다.
if token not in STOP_WORDS & (token.is_punct == False):
doc_tokens.append(token.text.lower())
tokens.append(doc_tokens)
df['tokens'] = tokens
5) 통계적 트리밍(Trimmig)
불용어를 직접 제거하는 방법 대신 코퍼스 내에 너무 많거나(문서를 분류할 때 너무 많아서 의미가 떨어짐) 적은(너무 적어서 굳이 없어도 됨) 토큰을 제거할 수도 있다.
wc = word_count(df['tokens']) # 단어 빈도 확인
wc = wc[wc['word_in_docs_percent'] >= 0.01] # 문서에서 빈도가 1% 이상인 단어들만 저장
6) 어간 추출(Stemming)
어간(stem)은 단어의 의미가 포함된 부분으로 접사 등이 제거된 형태이다. 어간 추출을 한 이후의 단어는 원래의 단어 형태가 아닐 수도 있다. ing, ed, s 등과 같은 부분을 제거한다.
Spacy는 Stemming은 제공하지 않고 Lemmatization만 제공하기 때문에 nltk 라이브러리로 stemming 을 할 수 있다. stemming을 하고나서는 단어의 뒷부분이 짤려서 원래의 단어 형태가 되지 않을 수 있지만, 알고리즘이 간단하고 속도가 빨라서 검색 분야에서 많이 사용된다.
from nltk.stem import PorterStemmer
ps = PorterStemmer()
tokens = []
for doc in df['tokens']:
doc_tokens = []
for token in doc:
doc_tokens.append(ps.stem(token))
tokens.append(doc_tokens)
df['stems'] = tokens
7) 표제어 추출(Lemmatization)
어간추출은 단어의 형태가 보존되지 않을 수 있기 때문에 기본 사전형 단어 형태인 Lemma(표제어)로 변환하는 방법도 있다. 표제어 추출은 명사의 복수형은 단수형으로, 동사는 모두 타동사로 변환된다. Stemming보다 많은 연산이 필요하지만 원래의 단어 형태를 나타낼 수 있다는 장점이 있다.
표제어 추출은 그 단어의 원래 형태가 어떤 단어인지 찾아야하는 시간이 걸리기 때문에 단순히 단어를 자르는 어간 추출보다 더 오랜 시간이 걸린다. 따라서, 속도가 더 중요한 경우 어간 추출, 정확성이 더 중요한 경우 표제어 추출을 사용하는 것이 적합하다.
def get_lemmas(text):
lemmas = []
doc = nlp(text)
for token in doc:
# 불용어가 아니고, 구두점이 없고, token.pos_ 는 품사를 나타냄. PRON은 대명사
# 즉, 대명사가 아닌 단어들
if ((token.is_stop == False) and (token.is_punct == False)) and (token.pos_ != 'PRON'):
lemmas.append(token.lemma_) # 표제어 추출
return lemmas
nlp = spacy.load("en_core_web_sm")
df['lemmas'] = df['text'].apply(get_lemmas)
등장 횟수 기반의 단어 표현(Count-based Representation)
Count-based Representation는 단어가 특정 문서나 문장에 들어있는 횟수를 바탕으로 해당 문서를 벡터화한다.
- 문서-단어행렬(Document-Term Matrix, DTM)
벡터화된 문서는 DTM 형태로 나타나는데 각 행은 문서, 열은 전체 문서의 단어로 이루어져 있다.
1) Bag-of_Words(BoW): TF(Term Frequency)
BoW는 가장 단순한 벡터화 방법 중 하나이다. 문서나 문장에서 문법이나 단어의 순서를 무시하고 단순히 단어의 빈도만 고려하여 벡터화한다. 가방 안에서 단어를 꺼내서 단어가 몇개인지 세는 것처럼 순서는 고려하지 않는다.
- CountVectorizer 적용
from sklearn.feature_extraction.text import CountVectorizer
import spacy
nlp = spacy.load("en_core_web_sm")
count_vect = CountVectorizer(stop_words='english', max_features=100) # 빈도수 상위 100개의 단어들만 사용
#dtm 만들기(문서가 행, 열은 단어, 값은 빈도)
dtm_count_df = count_vect.fit_transform(df['text'])
dtm_count_df = pd.DataFrame(dtm_count_df.todense(), columns=count_vect.get_feature_names())
2) Bag-of_Words(BoW): TF-IDF(Term Frequency - Inverse Document Frequency)
특정 문서에만 등장하고, 다른 문서에 등장하지 않은 단어는 그 문서를 대표하는 단어가 될 수 있기 때문에 중요하다고 할 수 있다. 이때 사용하는 개념이 TF-IDF이다.
TF는 특정 문서에서 단어 t가 쓰인 빈도이다. 분석할 문서에서 특정 단어(t)가 등장하는 횟수를 말한다.
IDF는 모든 문서의 수(n)를 단어 t가 들어있는 문서의 수로 나눈 뒤 로그를 취한 값이다. 0으로 나누는 것을 방지하기 위해 분모에 1을 더한다. IDF는 다른 문서에서는 잘 등장하지 않는 단어일수록(분모가 작음) 값이 커지게 될텐데 만약 log를 씌우지 않으면 희귀 단어에 너무 큰 가중치를 부여하기 때문에 log를 씌어서 이 격차를 줄인다. 예를 들어 전체문서가 10000개인데 t라는 단어가 1개의 문서에만 등장했다면 가중치가 5000이 되어서 스케일이 너무 커진다. 이를 log를 씌워서 스케일을 낮춰준다.
TF-IDF는 이 둘을 곱한 값으로, 특정 문서에서 단어 t가 많이 등장하고, 그 단어가 특정 문서에서만 많이 등장했다면 해당 단어는 특정 문서를 대표하는 단어로 중요한 단어가 된다. 만약, 단어 t가 특정 문서에서 많이 등장했는데, 다른 문서에서도 많이 등장한 단어면 TF값이 높더라도, IDF값이 낮아져서 중요하지 않은 단어로 나타낼 수 있다.
- TF-IDF Vectorizer 적용
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(stop_words='english', max_features=15) # 빈도수 상위 15개의 단어만 사용
#dtm 만들기(문서가 행, 열은 단어, 값은 tf-idf)
dtm_tfidf = tfidf.fit_transform(df['text'])
dtm_tfidf = pd.DataFrame(dtm_tfidf.todense(), columns=tfidf.get_feature_names())
! TF-IDF Vectorizer는 일반적인 빈도(정수)로 표현되는 Countvectorizer보다 훨씬 더 다양한 값을 가진 실수형으로 표현 된다.
파라미터 튜닝
SPaCy를 이용해서 Lemmatization을 하고 TF-IDF Vectorizer의 하이퍼 파라미터들을 튜닝해서 벡터화해보자.
from sklearn.feature_extraction.text import TfidfVectorizer
import spacy
nlp = spacy.load("en_core_web_sm")
def tokenize(document):
doc = nlp(document)
# 불용어가 아니고, 구두점 없고, 알파벳인 단어들 중 공백을 제거한 단어들을 리턴
return [token.lemma_.strip() for token in doc if (token.is_stop != True) and (token.is_punct != True) and (token.is_alpha == True)]
# tokenizer: 굳이 지정 안해줘도 자동으로 벡터화 해주지만, 디폴트 토큰화 외에 추가적인 작업(공백 제거 등)을 하고 싶으면
# 함수 만들어서 사용가능(위의 tokenize함수 처럼)
# ngram_range: 최소 n개 ~ 최대 m개를 갖는 n-gram을 토큰으로 사용. 최소 n개의 단어와 최대 m개의 단어까지의 단어묶음을 표현
# 만약 1,3이면 단어가 1개묶음 뿐만아니라 단어가 2개, 3개 묶음 인 것까지 index를 부여.
# min_df = n :최소 n개의 문서에서 등장하는 토큰만 사용한다.(int)
# max_df = m : m * 100% 이상 문서에 나타나는 토큰은 제거(float) 0.7 -> 10개의 문서중 7개 이상에서 등장한 단어는 대표성이 없으니까 제거
tfidf_tuned = TfidfVectorizer(stop_words='english'
,tokenizer=tokenize
,ngram_range=(1,2)
,max_df=.7
,min_df=3
)
dtm_tfidf_tuned = tfidf_tuned.fit_transform(df['text'])
dtm_tfidf_tuned = pd.DataFrame(dtm_tfidf_tuned.todense(), columns=tfidf_tuned.get_feature_names())
유사도(Similarity)
1) 코사인 유사도(Cosine Similarity)
코사인 유사도는 -1 ~ 1 사이의 값을 가지고 벡터들의 방향이 완전히 다르면 각도가 180도 유사도가 -1, 방향이 완전히 동일하면 180도 유사도가 1, 각이 90도면 0을 가진다.
하지만 보통 NLP에서는 단어 벡터 행렬이 음수값이 나오진 않으므로 코사인 유사도가 음수가 되지 않는다. 따라서 두 문서가 유사할수록 1에 가깝고, 유사하지 않을수록 0에 가깝다.
2) KNN(K NearestNeighbor, K-최근접 이웃)
KNN은 Euclidean Distance를 활용해서 벡터 사이의 거리를 구한다. 이 거리가 가까울수록 비슷한 벡터라고 할 수 있는데 KNN은 이 개념을 활용하여 거리가 가장 근접한 K 개의 이웃을 특정 벡터와 비슷한 벡터로 취급한다.
from sklearn.neighbors import NearestNeighbors
nn = NearestNeighbors(n_neighbors=5, algorithm='kd_tree')
nn.fit(dtm_tfidf)
2번째 인덱스에 해당하는 문서와 가장 가까운 문서와 거리를 알 수 있다. k를 5로 설정했기 때문에 5개의 이웃과 거리가 나온다.
nn.kneighbors([dtm_tfidf.iloc[2]])
2번과 5번 인덱스가 비슷하다면 이렇게 5번을 확인해볼 수가 있고
nn.kneighbors([dtm_tfidf.iloc[5]])
문서 행렬이 아닌 기존 데이터에서 어떻게 문장이 이루어져 있는지 확인할 수 있다.
print(df['text'][2][:100])
print(df['text'][5][:100])
N-gram
‘very’ 와 같이 단어가 하나로만 이루어져있으면 어떤 것을 의미하는지 잘 모르는 경우가 있다. 그냥 ‘very’라고 쓰였을 때보다 뒤에 형용사가 붙어서 ‘very good’, ‘very big’ 이라고 쓰였을 때 훨씬 의미 있는 단어가 된다. BoW에서는 빈도만 고려하고 단어의 순서를 무시하기 때문에 단어의 의미를 잘 파악할 수 없다는 단점이 있는데 n-gram을 사용함으로써 이를 해결할 수 있다.
n-gram은 연속적인 n개의 토큰으로 구성된 것으로, 이 토큰을 몇개의 연속된 토큰으로 구성할 지 정하는 방법이다. 1-gram(unigram)은 1개의 단어만 연속이기 때문에 일반적인 단어를 보는 것처럼 하나의 단어만 본다. 2-gram(bigram)은 연속으로 두개의 단어를 보고, 3-gram(trigram)은 3개의 단어를 연속으로 본다.
예를 들어, “I like apple and banana”라는 문장이 있으면 이것을 토큰화 한 뒤 n gram을 적용해보자.
1-gram은 I, like, apple, and, banana 로 단어를 분류한다.
2-gram은 I like, like apple, apple and, and banana로 단어를 분류한다.
3-gram은 I like apple, like apple and, apple and banana로 단어를 분류한다.
n-gram을 사용하면 단어의 순서가 고려되어 문맥을 파악할 수 있게 되고, 다음 단어를 예측할 수 있게 된다. 또한, Character 단위로 n-gram을 표현하면 어떤 문자가 주어졌을 때 그 뒤에 대부분 어떤 문자가 와야 일반적인 단어가 되는지 파악해서 오타를 발견할 수도 있다.