Scikit-learn의 CountVectorizer를 이용한 “관련 게시물 찾기”

비지도학습

머신러닝에는 크게 지도학습과 비지도학습이 있습니다. 데이터 자체 뿐만 아니라 데이터가 가리키는 정보(Label)를 함께 입력하여 학습시키는 것을 지도학습이라고 합니다. 이와 반대로 비지도학습에는 Label이 없습니다. 데이터 자체만을 전달할 뿐이죠. 비지도학습은 스스로 Label을 찾아냅니다. 데이터 자체에서 패턴을 찾아내어 일종의 Label을 만드는 것입니다.

이번 포스트에서는 '관련된 게시물 찾기'를 통해 비지도학습이 어떻게 이루어지는지 알아보겠습니다. 새로운 게시물이 입력되었을 때 Label이 붙어있지 않은 데이터 집합에서 그와 가장 비슷한 게시물을 어떻게 찾는지 방법을 알아보겠습니다.  <fn>Building Machine Learning Systems with Python</fn>

Vectorization

군집을 만들기 위해 가장 적절한 방법은 게시물마다 등장하는 단어의 빈도수를 파악해 하나의 카운트 벡터로 만듭니다. 이를 단어 주머니 접근 법이라고 합니다. 카운트 벡터 생성 후 해당 게시물과 다른 게시물 사이의 벡터 거리를 계산하여 게시물 사이의 유사도를 파악하면 됩니다.

"How to format my hard disk", "Hard disk format problems" 이 두 문장을 카운트 벡터로 만들어보겠습니다. Scikit-learn의 CountVectorizer를 이용하면 쉽게 구현할 수 있습니다.

# import를 해줍니다.
from sklearn.feature_extraction.text import CountVectorizer

# 하나의 Counter Vector를 만듭니다.
# 이 때 전달하는 인자 min_df는 해당 값보다 빈도수가 낮은 단어는 무시하겠다는 뜻입니다.
# max_df 인자도 있습니다. 마찬가지 의미로 해당 값보다 많이 나오는, 빈도수가 높은 단어는 무시하겠다는 뜻입니다.
vectorizer = CountVectorizer(min_df=1)
content = ["How to format my hard disk", "Hard disk format problems"]

# fit_transform 함수를 통해 Counter Vector로 만들 수 있습니다.
X = vectorizer.fit_transform(content)

# 출력해봅시다.
print(vectorizer.get_feature_names())
print(X.toarray())

* 출력값
['disk', 'format', 'hard', 'how', 'my', 'problems', 'to']
[[1 1 1 1 1 0 1]
[1 1 1 0 0 1 0]]

 

게시물 분류하기

 01) 카운트 벡터 만들기

짧은 글 5개를 이용하여 Count Vector의 거리를 계산해보도록 합시다.

짧은 글은 아래와 같습니다.

1) This is a toy post about machine learning. Actually, it contains not much interesting stuff.
2) Imaging databases provide storage capabilities.
3) Most imaging databases save images permanently.
4) Imaging databases store data.
5) Imaging databases store data. Imaging databases store data. Imaging databases store data.

from sklearn.feature_extraction.text import CountVectorizer
import os
import numpy as np

# 파일 리스트를 불러와 파일을 읽어 list에 저장해줍니다.
# os.listdir을 통해 해당 디렉토리 하위의 파일 경로를 모두 불러온 후
# os.path.join을 통해 디렉토리 경로를 합친 후 read()로 읽어 list에 담습니다.
posts = [open(os.path.join("C:₩₩python_data₩₩ch03₩₩data₩₩toy", f)).read() for f in os.listdir("D:₩₩python_data₩₩ch03₩₩data₩₩toy")]

# Count Vector를 만들었습니다. stop_words 인자는 불용어를 의미합니다.
vectorizer = CountVectorizer(min_df=1, stop_words="english")

위 코드에서 마지막 부분에 불용어라는 개념이 등장합니다. 불용어란 큰 의미는 없지만 자주 사용되는 단어들을 의미합니다. 예를 들어 among, almost, but, can, the 등이 있습니다. <fn>https://wikidocs.net/39</fn>

우리는 새로운 게시물이 주어졌을 때 해당 게시물이 어느 게시물과 거리가 가장 가까운지 살펴보려 합니다. 우선 새로운 게시물을 "Imaging databases"라고 하고 이 게시물의 Count Vector를 구해봅시다.

new_post = "imaging databases"
new_post_vec = vectorizer.transform([new_post])

transform()이라는 함수를 사용했습니다. 이전에는 fit_transform 함수를 사용하였는데요, 둘다 똑같이 Vectorization 시키는 함수인 것 같습니다. 다만 전자는 그 인자로 하나의 문자열을 받는 반면, 후자는 list 형태의 문자열을 받는 것 같습니다.

이쯤에서 transform 함수를 거치면 어떻게 생긴 녀석이 나오는지 살펴보겠습니다.

print(new_post_vec)

* 출력값
(0, 4)      1
(0, 6)      1

new_post_vec은 coo_matrix 구현체를 사용합니다. 위 출력값에서 우측은 1이라는 값을 나타내고 좌측의 순서쌍은 우측 값의 위치를 나타냅니다. 이처럼 coo_matrix를 이용하는 이유는 new_post_vec가 일종의 희소(sparse)이기 때문입니다. 희소는 대부분의 값이 0 또는 0에 가까운 실수를 가진 벡터를 의미합니다.

print(new_post_vec.toarray())

* 출력값
[[0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0]]  // 4번째와 6번째에 1이 위치해있고 나머지는 0입니다.

 

 02) 벡터 사이의 거리 구하기

이제 new_post_vec과 기존 Count Vector 사이의 거리를 구해보겠습니다. 일단 거리를 구하는 함수를 하나 정의합니다.

def dist_raw(v1, v2):
    delta = v1 - v2
    # norm(): 벡터 간에 유클리드 거리를 계산
    return np.linalg.norm(delta)

그 후 각 게시물과의 거리를 구해 출력합니다.

# 거리(best_dist)는 작을수록 좋습니다. 그렇기 때문에 초기값으로 일단 큰 값을 입력해 놓습니다.
import sys
best_dist = sys.maxsize
best_doc = None
best_i = None

for i, post in enumerate(posts):
    post_vec = X_train.toarray()[i]
    # 벡터 사이의 거리를 구하기 위해서는 1차원 배열을 이용해야 하기 때문에 [0], 인덱스를 지정합니다.
    d = dist_raw(post_vec, new_post_vec.toarray()[0])
    print("=== Post %i with dist = %.2f: %s" %(i, d, post))
    if d<best_dist:
        best_dist = d
        best_i = i

print("Best post is %i with dist=%.2f" % (best_i, best_dist))

* 출력값
=== Post 0 with dist = 4.00: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist = 1.73: Imaging databases provide storage capabilities.
=== Post 2 with dist = 2.00: Most imaging databases save images permanently.
=== Post 3 with dist = 1.41: Imaging databases store data.
=== Post 4 with dist = 5.10: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 3 with dist=1.41

 

3번 게시물이 새로운 게시물과 거리가 가장 가깝습니다. 다만 한가지 아쉬운 점은 3번, 4번 게시물은 단지 길이 차이인데도 불구하고 4번 게시물과의 거리가 더 멀다고 나왔습니다. 이를 교정하기 위해서는 카운트 벡터를 정규화해주어야 합니다. 위에서 정의한 거리 구하는 함수 dist_raw를 수정함으로써 문제를 간단히 해결할 수 있습니다.

def dist_norm(v1, v2):
    v1_normalized = v1/np.linalg.norm(v1)
    v2_normalized = v2/np.linalg.norm(v2)
    delta = v1_normalized - v2_normalized
    # norm(): 벡터 간에 유클리드 거리를 계산
    return np.linalg.norm(delta

이후 새로운 출력값을 아래와 같습니다.

=== Post 0 with dist = 1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist = 0.86: Imaging databases provide storage capabilities.
=== Post 2 with dist = 0.86: Most imaging databases save images permanently.
=== Post 3 with dist = 0.77: Imaging databases store data.
=== Post 4 with dist = 0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 3 with dist=0.77

 

 03) 어근 추출 / 불용어 강화

전반적인 작업은 끝났습니다. 거리도 구했고 정규화도 끝냈습니다. 마지막 부가적인 사항 '어근 추출'과 '불용어 강화'입니다.

우선 어근 추출은 imaging과 image를 같은 단어로 인식하게 하는 작업입니다. 다시 말해 모든 단어를 '어근'으로 만든 상태에서 Count Vector를 만드는 것이 더 좋겠다는 생각입니다. 어근 추출은 NLTK(Natural Language Toolkit)을 통해 가능합니다. pip를 통해 nltk를 설치해줍니다.

NLTK를 설치했다면 이를 CountVectorizer 클래스에 재정의(Overwrite)하여야 합니다. CountVectorizer 클래스는 단지 토큰화와 정규화에만 초점을 맞춘 객체이기 때문입니다.

import nltk.stem
# SnowballStemmer에 인자로 english를 전달해 언어가 영문일 경우의 어근을 추출합니다.
english_stemmer = nltk.stem.SnowballStemmer('english')

class StemmedCountVector(CountVectorizer):
    # CountVectorizer의 build_analyzer 함수를 재정의 합니다.
    def build_analyzer(self):
        analyzer = super(StemmedCountVector, self).build_analyzer()
        # 토큰화 된 단어(w)를 어근화(stem) 시킨 후 리턴합니다.
        return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))

두번째는 불용어 강화입니다. 우리는 위에서 min_df, max_df 혹은 stop_words 등의 인자를 통해 불용어, 다시 말해 주제와 연관없는 단어들을 걸러냈습니다. 하지만 이는 우리 임의대로 정한 수치였습니다. TF-IDF를 이용하면 이를 한번에 해결할 수 있습니다. TF는 단어를 세고 IDF는 단어를 무시하는 부분을 고려합니다. 이로써 전체적으로는 매우 드물지만 해당 게시물에 자주 나타나는 단어를 좀 더 부각시킬 수 있습니다.

이를 실제로 구현하는 것은 매우 어렵습니다. 그렇기 때문에 Scikit-learn에서는 TfidfVectorizer를 통해 쉽게 구현할 수 있도록 도와줍니다.

from sklearn.feature_extraction.text import TfidfVectorizer

# 단순하게 상속받는 클래스 이름만 바꿔주면 됩니다.
class StemmedCountVector(TfidfVectorizer):
 def build_analyzer(self):
 analyzer = super(StemmedCountVector, self).build_analyzer()
 return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))

vectorizer = StemmedCountVector(min_df=1, stop_words="english", decode_error='ignore')

* 최종 출력값
=== Post 0 with dist = 1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist = 1.08: Imaging databases provide storage capabilities.
=== Post 2 with dist = 0.86: Most imaging databases save images permanently.
=== Post 3 with dist = 0.92: Imaging databases store data.
=== Post 4 with dist = 0.92: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 2 with dist=0.86

 

 

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다