DoITgrow

[자연어 처리] TF-IDF (Term Frequency-Inverse Document Frequency) - 파이썬(Python) 본문

딥러닝 & 머신러닝/자연어 처리 (Natural Language Processing)

[자연어 처리] TF-IDF (Term Frequency-Inverse Document Frequency) - 파이썬(Python)

김수성 (Kim SuSung) 2021. 9. 23. 14:25
반응형

이전 포스트에서 Bag of Word(BoW)의 개념을 알아보았고, BoW는 텍스트 문서(또는 문장)를 컴퓨터가 이해할 수 있는 데이터로 변환하는 간단한 알고리즘이라는 것을 코드를 직접 구현해보며 알아보았다.

2021.09.23 - [자연어 처리] Bag of Word (BoW) - 파이썬(Python)

그러나 텍스트 정보를 BoW를 통해 언어 모델로 해석하려고 한다면 몇 가지 문제점이 존재한다.

Bag of Word의 한계

1. 불용어(의미 없는 단어)를 제대로 제거하지 못하면 원하지 않는 편향된(biased) 결과가 얻어질 수 있다.
2. 문장(또는 문서)의 의미가 단어 순서에 따라 달라질 수 있지만 BoW 모델은 이를 반영할 수 없다.

언급한 2가지 한계점 중 첫 번째의 사례에 대해 구체적으로 설명하며, 이를 어느 정도 해결할 수 있는 TF-IDF 모델을 소개하려고 한다.

참고로 두번째 한계점에 대해 다루지 않는 이유는 이 또한 TF-IDF 모델로도 해결할 수 없는 문제이기 때문이다. 물론 기존 모델에서도 이를 해결하기 위한 방법으로 n-gram을 사용하기도 한다. 하지만 n-gram을 사용하면 벡터의 차원이 기하급수적으로 늘어나거나, 벡터가 희소(Sparse) 형태로 표현될 가능성이 높아져서 성능에 영향을 미칠 수 있기 때문에 또 다른 문제가 발생할 수 있다.

참고로 단어 순서를 고려하기 위한 언어 모델로 2018년까지는 RNN 기반의 LSTM, GRU 딥러닝 모델을 많이 사용했고,  2018년에 구글에서 발표한 BERT(Bidirectional Encoder Representation from Transformer)가 엄청난 자연어 처리 성능을 보여주며, BERT를 통해 파생된 다양한 언어 모델이 발표되고 있다. 이 부분은 나중에 별도의 포스트에서 다루고자 한다.

 

불용어(의미 없는 단어)를 제대로 제거하지 못하면 원하지 않는 편향된(biased) 결과가 얻어질 수 있다.

한계점을 보다 쉽게 설명하기 위해 단순한 상황을 가정해보자.

아래와 같은 3개의 문장이 있고, 해당 문장에는 중괄호( { } )와 같은 단어들로 구성되어 있다고 하자. 
문장 A : {나는 : 2개, 사과를 : 1개, 맛있게 : 1개, 먹었다 : 2개, 그리고 : 10개}
문장 B : {나는 : 2개, 바나나를 : 1개, 맛있게 : 1개, 먹었다 : 2개}
문장 C : {동생은 : 1개, 사과를 : 1개, 샀다 : 2개, 그리고 : 10개} 

위 문장들을 구성하고 있는 단어들을 대략적으로 살펴 표면, "문장 A"와 "문장 B"가 유사한 것으로 생각이 든다. 문장 A, B는 내가 무언가를 맛있게 먹었다는 내용이고, "문장 C"는 동생이 사과를 샀다는 내용으로 문장 A, B 와는 다른 의미를 가진 문장이라고 생각할 수 있다.

 

그럼 위의 문장 A, B, C를 아래 테이블과 같이 BoW로 만들어서 문장 간 유사도를 직접 확인해 보자.

  나는 사과를 맜있게 먹었다 그리고 바나나를 샀다
문장 A 2 1 1 2 10 0 0
문장 B 2 0 1 2 0 1 0
문장 C 0 1 0 0 10 0 2

문장 간의 유사도 확인

문서(또는 문장)에서 벡터들을 이용하여 상호 간의 유사도를 구하는 방법에는 대표적으로 유클리디안 거리(Euclidean Distance), 코사인 유사도(Cosine Similarity), 자카드 유사도(Jaccard Similarity)가 있다. 이러한 유사도들은 벡터간 거리를 비교하는 방법이 달라서 데이터 분석 특성에 맞게끔 사용하는 것이 좋다. 여기서는 대표적으로 코사인 유사도를 이용하여 위 3가지 문장들 간의 유사도를 확인한다.
참고로 코사인 유사도는 -1 ~ 1 범위의 값을 가지며, 1에 가까울수록 두 문장의 유사도가 높다는 의미이다. 

### similarity_between_sentences_1.py

import numpy as np

def cosine_similarity(x, y):
	return np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y))

sentA = [2, 1, 1, 2, 10, 0, 0]
sentB = [2, 0, 1, 2, 0, 1, 0]
sentC = [0, 1, 0, 0, 10, 0, 2]

>>> print("문장 A와 문장 B의 유사도: {}".format(round(cosine_similarity(sentA, sentB), 2)))
>>> 문장 A와 문장 B의 유사도: 0.27

>>> print("문장 A와 문장 C의 유사도: {}".format(round(cosine_similarity(sentA, sentC), 2)))
>>> 문장 A와 문장 C의 유사도: 0.94

위 코드와 같이 코사인 유사도 함수를 이용하여 문장 간의 유사도를 평가해 보았다.
결과를 보니 문장 A, B보다는 문장 A, C가 더 유사하다는 결과가 나왔다. 그러나 실제 사람이 판단하기로는 문장 A, B가 더 유사하다고 생각할 것이다.

왜 이런 결과가 나왔을까? 바로 문장 내에서 의미를 가지고 있지 않은 "그리고" 단어 때문이다. 이러한 단어는 문서의 성격을 나타내는 단어이기보다는 큰 의미 없이 범용적으로 사용할 수 있는 단어이다. 다시 말해 의학 관련 문서에서도 많이 나타날 수 있고, 농업 관련 문서나 동화책에서도 많이 나타날 수 있다. 또한 "그리고"는 잦은 빈도로 사용하는 단어이므로 적절하게 처리를 해주지 않으면 이를 특징으로 문서가 유사하게 분류될 수 있다.

 

그러면 "그리고"를 불용어로 잘 제거를 했다면 어떠한 결과를 볼 수 있을까?

### similarity_between_sentences_2.py

import numpy as np

def cosine_similarity(x, y):
	return np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y))

sentA = [2, 1, 1, 2, 0, 0] # 5번째의 "그리고" 데이터 삭제
sentB = [2, 0, 1, 2, 1, 0] # 5번째인 "그리고" 데이터 삭제
sentC = [0, 1, 0, 0, 0, 2] # 5번째인 "그리고" 데이터 삭제

>>> print("문장 A와 문장 B의 유사도: {}".format(round(cosine_similarity(sentA, sentB), 2)))
>>> 문장 A와 문장 B의 유사도: 0.9

>>> print("문장 A와 문장 C의 유사도: {}".format(round(cosine_similarity(sentA, sentC), 2)))
>>> 문장 A와 문장 C의 유사도: 0.14

위 코드를 통해 다시 결과를 확인해 보자. 이제는 우리가 생각한 것과 같이 문장 A, B의 유사도가 문장 A, C 유사도 보다 높다고 나온 것을 볼 수 있다.

 

그럼 불용어를 잘 제거하면 되는 것일까?

정답은 "그렇다"이다. 단, 문서(또는 문장)를 정확하게 대표하는 단어들로만 데이터를 처리할 수 있으면 그렇다는 이야기이다. 사실 이게 불가능하다. 가능하려면 가지고 있는 문서 전부를 전수 검사하여 의미 있는 단어만 남겨야 하는데, 가지고 있는 문서가 80만 개라면 이런 작업을 할 엄두도 못 낼 것이다.

기본적으로 "자연어 처리" 패키지에는 정의해 놓은 불용어가 있기에 이를 활용하여 데이터 처리를 하지만 분야에 따라서 불용어로 될 수 있는 무수한 단어들이 존재한다.

예를 들어 논문에서는 research라는 단어가 문서마다 많이 존재한다. 그리고 특허에서는 invention과 같은 단어들이 많이 존재한다. 그리고 우리가 알 수 없는 어떤 범용적인 단어가 존재할 수도 있다. 따라서 우리는 기본적인 불용어 사전을 구축하여 제거하지만 혹시라도 남아있는 불용어에 의해 결과가 왜곡되는 것을 막고자 TF-IDF 방법을 사용할 수 있다.

 

TF-IDF(Term Frequency-Inverse Document Frequency)는 무슨 역할을 할까?

TF-IDF 방법을 설명하기 앞서 아래와 같이 문서를 모델링하기 위한 사고 과정을 해볼 수 있다.

문서의 특징은 출현하는 단어 빈도수에 영향을 받을 거야. 왜냐하면 의학 관련 기사에서는 농업과 관련된 단어가 나오지 않을 거니까. 그래서 문서에서 빈도가 높은 단어들로 문서를 특징지으면 될 거야.

근데 문서에서 중요한 단어는 많이 나오긴 할 테지만 단순히 빈도 순으로 파악하면 일반적으로 자주 쓰이는 단어들에 비해서는 빈도가 낮을 것 같은데... 그리고 이러한 일반적인 단어들을 일일이 모두 제거하는 것도 불가능할 것 같고...

그러면 문서를 특징짓는 중요한 단어들은 너무 적게 나오지도 않고, 너무 많이 나오지도 않는 단어겠네!!!

위의 사고 과정을 통해 문서를 특징짓는 중요한 단어는 너무 적게 나오지도 너무 많이 나오지도 않는 것이라고 생각했다. 결과적으로 너무 많이 출현했거나 너무 적게 출현한 단어들에는 페널티를 부여하고, 적절하게 출현한 단어들에는 가중치를 부여하여 문서의 특징을 더 많이 대변하도록 만들어 주는 것이 바로 TF-IDF 방법의 핵심이다. 

Term Frequency (TF) : 한 문서 내에서 특정 단어의 빈도수
Inverse Document Frequency (IDF) : 전체 문서에서 특정 단어가 출현한 빈도 수가 클수록 작아지는 값

특정 단어에 대한 TF-IDF 결과 값을 수식으로 나타내면 아래와 같다.

$w_{i, j} = tf_{i, j} \times log({N \over df_{i}})$

여기서 $tf_{i, j}$ 는 단어 $i$ 가 문서 $j$ 에서 출현하는 빈도수를 의미하며, $N$ 은 문서 전체 개수, $df_{i}$ 는 전체 문서에서 단어 $i$ 가 출현하는 빈도수를 나타낸다.

 

$tf_{i, j}$ 항은 직관적으로 이해할 수 있을 것이고, $log({N \over df_{i}})$ 항이 의미하는 기하학적 의미를 살펴보자.

위의 문장 A, B, C의 예시를 가져와서 $N=3$ 인 경우의 상황에서 $df_{i}$가 변함에 따라 어떻게 값이 달라지는지를 살펴보자.

$ log({3 \over x})$ 

위 그래프와 같이 $x = 3$ 일 때($df_{i} = 3$ 일 때), IDF의 값은 0이 되는 것을 알 수 있다. 즉, 모든 문서에서 출현한 단어는 범용적인 단어일 확률이 높으므로  $tf_{i, j}$ 에 0을 곱해서 단어의 중요도를 낮추어 줄 수 있다.

 

반대로 $x = 0$ 일 때($df_{i} = 0$ 일 때)는 값이 무한대로 발산하고 있다. 그러나 IDF 값이 매우 큰 수가 나오더라도 $x = 0$ 이면 TF의 값도 거의 0으로 수렴하기 때문에 2개의 곱은 매우 작은 숫자가 나올 것이다. 

 

TF-IDF 적용 후 유사도 비교

그럼 돌아가서 문장 A, B, C의 데이터를 이용하여 TF-IDF 적용 후의 유사도를 비교해 보자.

  나는 사과를 맜있게 먹었다 그리고 바나나를 샀다
문장 A 2 1 1 2 10 0 0
문장 B 2 0 1 2 0 1 0
문장 C 0 1 0 0 10 0 2

위 데이터는 단순한 상황을 가정한 것이었으며, 각 단어들의 $df_{i}$ 값은 {'나는': 2, '사과를': 2, '맛있게': 2, '먹었다': 2, '그리고': 2, '바나나를': 1, '샀다': 1} 이 된다. 그럼 $N = 3$이고, 단어가 출현한 문서 개수가 2인 ($df_{i} = 2$) "나는", "사과를", "맛있게", "먹었다", "그리고"는 모두 같은 $log({N \over df_{i}})$ 의 값을 가지게 된다.
따라서 이 데이터를 그대로 사용하면 의미가 없고, 더 현실적인 상황의 데이터로 돌아가는 것이 필요하다. 그럼 현실 데이터답게 전체 문장을 300개 수집했고, 각 단어들이 출현한 문서의 개수가 {'나는': 200, '사과를': 100, '맛있게': 100, '먹었다': 180, '그리고': 280, '바나나를': 60, '샀다': 120}라고 생각해보자.

대략적으로 데이터를 3개에서 300개로 100배 늘렸으니 아래의 문장별 단어의 개수도 100배가 늘었다고 임의로 가정하자.

  나는 사과를 맜있게 먹었다 그리고 바나나를 샀다
문장 A 200 100 100 200 1000 0 0
문장 B 200 0 100 200 0 100 0
문장 C 0 100 0 0 1000 0 200

그러면 대표적으로 "나는""그리고"에 대한 단어의 TF-IDF 값을 계산해 보자.

"나는" 단어의 $log({N \over df_{i}})$은 $log({300 \over 200})$이 되어 약 $0.18$의 값으로 계산할 수 있다.

"그리고" 단어의 $log({N \over df_{i}})$은 $log({300 \over 280})$이 되어 약 $0.03$의 값으로 계산할 수 있다.

 

그리고 TF와 IDF 값을 곱해서 문장 A에서의 "나는" 단어와 "그리고" 단어의 TF-IDF 값을 계산해 보자.

"나는" 단어의 TF 값은 200 이고, IDF는 약 0.18 이므로 이를 곱하면 되고, "그리고 단어의 TF 값은 1000이고, IDF는 약 0.03 이므로 이를 곱하면 TF-IDF 값을 얻을 수 있다.

 

이렇게 문장 A, B, C에 대해 모든 단어들의 TF-IDF를 계산한다.

### TFIDF.py

import numpy as np
import pandas as pd

sent_corpus = ['문장 A', '문장 B', '문장 C']
vocabs = ['나는', '사과를', '맛있게', '먹었다', '그리고', '바나나를', '샀다']
df_i = np.array([200, 100, 100, 180, 280, 60, 120]) # 단어가 출현하는 문서의 개수

sents = np.array([[200, 100, 100, 200, 1000, 0, 0], [200, 0, 100, 200, 0, 100, 0], [0, 100, 0, 0, 1000, 0, 200]])
IDF = np.log10(300 / df_i)

TFIDF_sents = []
for sent in sents:
    TFIDF = list(np.round(sent * IDF, 1))
    TFIDF_sents.append(TFIDF)

print(TFIDF_sents)
>>> [[35.2, 47.7, 47.7, 44.4, 30.0, 0.0, 0.0], # 문장 A
     [35.2, 0.0, 47.7, 44.4, 0.0, 69.9, 0.0], # 문장 B
     [0.0, 47.7, 0.0, 0.0, 30.0, 0.0, 79.6]] # 문장 C

위 코드와 같이 TF-IDF 를 계산하면 문장별 각 단어의 값들이 문서 특징에 따라 조정된 값으로 나오게 된다.

그러면 다시 돌아가서 코사인 유사도를 구해서 문장 간의 유사도를 확인해 보자.

### similarity_between_sentences_3.py

import numpy as np

def cosine_similarity(x, y):
	return np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y))

sentA, sentB, sentC = TFIDF_sents

print("문장 A와 문장 B의 유사도: {}".format(round(cosine_similarity(sentA, sentB), 2)))
>>> 문장 A와 문장 B의 유사도: 0.58
print("문장 A와 문장 C의 유사도: {}".format(round(cosine_similarity(sentA, sentC), 2)))
>>> 문장 A와 문장 C의 유사도: 0.35

이제 TF-IDF 를 적용한 문장 벡터들로 유사도를 구해보니 원하는 대로 문장 A, B의 유사도가 문장 A, C의 유사도 보다 높은 것을 확인할 수 있다. 사실 위쪽에서 미리 확인해 봤듯이 "그리고"라는 불필요한 단어를 아예 제거하는 것보다는 성능이 좋아지진 않는다. 그러나 불용어를 정의하고 모두 제거하는 것이 현실적으로 불가능하다는 것을 숙지하고 있다면 이 정도의 성능이 좋아진 것도 괜찮은 결과라 생각할 수 있다.

그리고 여기서는 쉽게 설명하고자 작은 수의 데이터를 가정했지만, 데이터가 더 많아진다면 정확도가 더욱 높아질 것이다.

TF-IDF를 이용하여 현업에서 분석했던 경험을 생각해보면 TF-IDF는 매우 큰 분류의 문서를 분류하는 것에는 성능이 좋으나 작은 분류의 문서를 분류하는 데에는 적합하지 않다고 생각한다. 예를 들어 내가 분류되지 않은 컴퓨터 사이언스 관련 문서와 의학 관련 문서가 있다고 했을 때, 각 문서에서 사용하는 단어들의 구별이 쉬우므로 TF-IDF 방법으로 분류하면 생각보다 괜찮은 성능을 낼 수 있다. 그러나 컴퓨터 사이언스 관련 문서 중에서 AI, 머신러닝, 딥러닝 등의 세부 분류로 나누고자 하면 대부분 비슷한 용어를 사용하기 때문에 제대로 된 성능을 확인하기 어렵다.

 

그래서 더 디테일한 작업을 위해서는 Word2Vec, Doc2Vec, BERT 등을 활용하여 단어, 문서를 의미가 부여된 벡터 공간에 임베딩 시키는 방법이 필수적인 것 같다. 해당 내용도 공부하며, 점차 포스팅할 예정이다.

 

긴 글 읽어주셔서 감사합니다. 문의사항이나 잘못된 부분이 있으면 언제든지 지적 부탁드립니다~ ^^
반응형
Comments