TF-IDF(Term Frequency, Inverse Document Frequency)는
BoW의 단점 중 하나인 자주 등장하는 용어가 가중치를 받게 된다는 점을 보완하기 위한 다른 벡터라이제션 방법입니다.
아이디어는 간단합니다.
도큐먼트들의 집합 코퍼스(corpus)가 있다고 가정했을 때, 전 코퍼스에 걸쳐 대다수의 document(하나의 row)들에 자주 등장하는 단어는 this, that 처럼 별로 중요하지 않은 단어들일 가능성이 높고, 특정한 document들에만 집중되어 등장하는 단어는 의미있는 단어들일 가능성이 높다라는 것입니다.
먼저 Term-Frequency즉 단어의 빈도 수 TF는 다음과 같은 형태로 표시할 수 있을 겁니다.
여기서 는 word를 의미하고 는 document를 의미합니다.
즉 d번째 document에 term 가 몇번 들어갔는지를 보여주는 수식입니다.
다만 이는 document마다 편차가 심할 수 있어 약간의 완충작용을 위해 단조증가함수인 로그를 통해 다음과 같이 표현합니다.
반면 idf는 다음과 같습니다.
여기서 대문자 는 document가 아닌 전체 코퍼스입니다.
그리고 은 전체 documents의 수, 즉 전체 row수가 됩니다.
는 단어 를 지니고있는 도큐먼트들의 수 입니다.
이 식의 의미는 자명합니다. 전체 documents의 수를 term을 보유한 documents수로 나눠줌으로써 해당 단어가 적은 수의 documents에 분산되어있을 경우에 idf값이 증가하게 됩니다.
정리하면 특정한 document 와 term 에 대하여 TF는 특정한 document에서 term이 얼마나 자주 등장하는지를 의미하고 IDF는 다른 documents들에서는 term이 얼마나 적게 등장하는지를 의미합니다.
즉, 내 단어 t가 document에서는 많고 다른 documents에서는 적을 수록 증가한다는 거네요? TF-IDF 스코어는 다음과 같이 계산됩니다.
IDF를 이용하기 때문에 BoW의 단점이었던 어디든 자주 등장하는 단어의 가중치가 편향되는 문제를 해결할 수 있을 것으로 보입니다.
이렇게 TF-IDF스코어를 각 token vector위치에 넣는 것이 TF-IDF 기술입니다.
역시 희소행렬이나 단어간의 유사성을 표현하지 못하는 등 다양한 문제점은 해결되지 않고 그대로 남아있는 것을 확인할 수 있습니다.
워낙 역사가 깊은 기술이라 다양한 변형이 존재합니다.
다음은 scikit-learn에 있는 버전입니다.
말하고자 하는 바는 유사합니다. 분자와 분모에 +1을 해주는건 0으로 나누는 상황을 예방하기 위해서겠네요.
간단하게 먼저 프리프로세싱을 해줍니다.
# Like before, if we want to use spaCy's tokenizer, we need
# to create a callback. Remember to upgrade spaCy if you need
# to (refer to beginnning of file for commentary and instructions).
nlp = spacy.load('en_core_web_sm')
# We don't need named-entity recognition nor dependency parsing for
# this so these components are disabled. This will speed up the
# pipeline. We do need part-of-speech tagging however.
unwanted_pipes = ["ner", "parser"]
# For this exercise, we'll remove punctuation and spaces (which
# includes newlines), filter for tokens consisting of alphabetic
# characters, and return the lemma (which require POS tagging).
def spacy_tokenizer(doc):
with nlp.disable_pipes(*unwanted_pipes):
return [t.lemma_ for t in nlp(doc) if \
not t.is_punct and \
not t.is_space and \
t.is_alpha]
TfidfVectorizer를 이용해서 코퍼스의 벡터화를 진행합니다.
%%time
# Use the default settings of TfidfVectorizer.
vectorizer = TfidfVectorizer(tokenizer=spacy_tokenizer)
features = vectorizer.fit_transform(corpus.data)
# The number of unique tokens.
print(len(vectorizer.get_feature_names_out()))
###
9440
# The dimensions of our feature matrix. X rows (documents) by Y columns (tokens).
print(features.shape)
###
(593, 9440)
# What the encoding of the first document looks like in sparse format.
print(features[0])
###
(0, 5064) 0.10452754121963853
(0, 2351) 0.12747025764625855
(0, 4340) 0.15331700873692364
(0, 2459) 0.10862435105627101
(0, 4916) 0.17102715751031994
(0, 6702) 0.09940033595823265
(0, 5982) 0.10183554382071024
(0, 6514) 0.08455482269873241
(0, 896) 0.0892999596249832
(0, 316) 0.1109487112663238
(0, 4896) 0.08247641364333849
(0, 628) 0.051044670776703174
(0, 4368) 0.10270174012167517
(0, 5274) 0.13259746290766442
(0, 6908) 0.12524708704889775
(0, 2494) 0.07376562213268434
(0, 8105) 0.09513204666042695
(0, 3287) 0.051874685324429695
(0, 6181) 0.1390186329543497
(0, 5652) 0.11219531673533985
(0, 4589) 0.06321728493925476
(0, 9158) 0.06158004812009137
(0, 1141) 0.048918909156680825
(0, 5023) 0.12320196834845284
(0, 6354) 0.15331700873692364
...
이렇게 ti-idf로 벡터라이제이션까지 해봤으니,
특정한 쿼리(검색)에 대해 가장 유사한 document를 찾는 예제를 해보겠습니다.
먼저 위에서 정의했던 벡터라이저(tf-idf)로 쿼리를 transform 해줍니다.
# Transform the query into a TF-IDF vector.
query = ["lunar orbit"]
query_tfidf = vectorizer.transform(query)
그리고 모든 피처벡터들에 대한 코사인유사도를 계산합니다.
# Calculate the cosine similarities between the query and each document.
# We're calling flatten() here becaue cosine_similarity returns a list
# of lists and we just want a single list.
cosine_similarities = cosine_similarity(features, query_tfidf).flatten()
cosine_similarities
다음과 같이 배열을 정렬하여 높은 순위를 뽑아내는 함수를 만들고 t5를 뽑아내봅시다.
리턴부분에 argsort(arr)로 배열을 ascending order로 정렬하고 [:kth_largest:-1]로 가장 큰 5개를 뽑아온다음 -1을 통해 다시 reverse order로 정렬한 것입니다.
import numpy as np
# numpy's argsort() method returns a list of *indices* that
# would sort an array:
# https://numpy.org/doc/stable/reference/generated/numpy.argsort.html
#
# The sort is ascending, but we want the largest k cosine_similarites
# at the bottom of the sort. So we negate k, and get the last k
# entries of the indices list in reverse order. There are faster
# ways to do this using things like argpartition but this is
# more succinct.
def top_k(arr, k):
kth_largest = (k + 1) * -1
return np.argsort(arr)[:kth_largest:-1]
# So for our query above, these are the top five documents.
top_related_indices = top_k(cosine_similarities, 5)
print(top_related_indices)
###
[249 108 0 312 509]
# Let's take a look at their respective cosine similarities.
print(cosine_similarities[top_related_indices])
###
[0.47855355 0.4292246 0.2736328 0.19486489 0.19125175]
# Top match.
print(corpus.data[top_related_indices[0]])
###
Actually, Hiten wasn't originally intended to go into lunar orbit at all,
so it indeed didn't have much fuel on hand. The lunar-orbit mission was
an afterthought, after Hagoromo (a tiny subsatellite deployed by Hiten
during a lunar flyby) had a transmitter failure and its proper insertion
into lunar orbit couldn't be positively confirmed.
It should be noted that the technique does have disadvantages. It takes
a long time, and you end up with a relatively inconvenient lunar orbit.
If you want something useful like a low circular polar orbit, you do have
to plan to expend a certain amount of fuel, although it is reduced from
what you'd need for the brute-force approach.
[1] nlpdemystified, https://www.nlpdemystified.org/