코사인 유사도의 의미

일반적으로 문서간의 유사도를 비교할 때는 코사인 유사도cosine similarity를 주로 사용한다. 이 글에서는 그 동안 습관적으로 사용해오던 코사인 유사도의 의미를 수식과 함께 예제를 통해 정확히 이해해보도록 한다.

2018년 6월 1일 초안 작성

본문

여기서 사용한 주피터 코드와 설명은 Euclidean vs. Cosine Distance에서 가져왔다. 코사인 유사도의 의미에 대해 매우 깔끔하게 잘 정리한 글이다. 작성자는 강의 중 “언제 코사인 유사도를 사용하나요?”라는 학생의 질문을 받고 글을 작성했다고 한다.

데이터 준비

먼저 아래와 같은 데이터를 준비하고 시각화 해본다.

X = np.array([[6.6, 6.2, 1],
              [9.7, 9.9, 2],
              [8.0, 8.3, 2],
              [6.3, 5.4, 1],
              [1.3, 2.7, 0],
              [2.3, 3.1, 0],
              [6.6, 6.0, 1],
              [6.5, 6.4, 1],
              [6.3, 5.8, 1],
              [9.5, 9.9, 2],
              [8.9, 8.9, 2],
              [8.7, 9.5, 2],
              [2.5, 3.8, 0],
              [2.0, 3.1, 0],
              [1.3, 1.3, 0]])

df = pd.DataFrame(X, columns=['weight', 'length', 'label'])

ax = df[df['label'] == 0].plot.scatter(x='weight', y='length', c='blue', label='young')
ax = df[df['label'] == 1].plot.scatter(x='weight', y='length', c='orange', label='mid', ax=ax)
ax = df[df['label'] == 2].plot.scatter(x='weight', y='length', c='red', label='adult', ax=ax)
ax

키와 몸무게에 따른 나이를 추측하는 가상의 데이터로 young, mid, adult 세 가지 클래스는 두 가지 피쳐로 매우 잘 구분된다. k-NN을 적용한다고 가정할때 어떤 거리 메트릭distance metric을 사용하는 것이 적절한지 살펴보자.

메트릭 선별

0, 1, 4번 인스턴스를 선별하여 14번 인스턴스에 어떤 레이블을 부여하는게 적절한지 살펴보자.

df2 = pd.DataFrame([df.iloc[0], df.iloc[1], df.iloc[4]], columns=['weight', 'length', 'label'])
df3 = pd.DataFrame([df.iloc[14]], columns=['weight', 'length', 'label'])

ax = df2[df2['label'] == 0].plot.scatter(x='weight', y='length', c='blue', label='young')
ax = df2[df2['label'] == 1].plot.scatter(x='weight', y='length', c='orange', label='mid', ax=ax)
ax = df2[df2['label'] == 2].plot.scatter(x='weight', y='length', c='red', label='adult', ax=ax)
ax = df3.plot.scatter(x='weight', y='length', c='gray', label='?', ax=ax)
ax

유클리드 거리

유클리드 거리의 수식은 다음과 같다.

코드와 계산 결과는 아래와 같다.

def euclidean_distance(x, y):   
    return np.sqrt(np.sum((x - y) ** 2))
x0 = X[0][:-1]
x1 = X[1][:-1]
x4 = X[4][:-1]
x14 = X[14][:-1]
print(" x0:", x0, "\n x1:", x1, "\n x4:", x4, "\nx14:", x14)

print(" x14 and x0:", euclidean_distance(x14, x0), "\n",
      "x14 and x1:", euclidean_distance(x14, x1), "\n",
      "x14 and x4:", euclidean_distance(x14, x4))
--

 x0: [6.6 6.2] 
 x1: [9.7 9.9] 
 x4: [1.3 2.7] 
x14: [1.3 1.3]

 x14 and x0: 7.218032973047436 
 x14 and x1: 12.021647141718974 
 x14 and x4: 1.4000000000000001

유클리드 거리에 따르면 4번 인스턴스와 가장 가까우며, 따라서 k-NN을 적용했을때 young 클래스로 추측할 수 있다. 우리의 직관과 일치하는 나쁘지 않은 결과다.

코사인 유사도

이제 코사인 유사도를 적용해보자. 수식은 아래와 같다.

마찬가지로 코드 구현과 값을 출력해보도록 한다.

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

print(" x14 and x0:", cosine_similarity(x14, x0), "\n",
      "x14 and x1:", cosine_similarity(x14, x1), "\n",
      "x14 and x4:", cosine_similarity(x14, x4))
--
 x14 and x0: 0.9995120760870786 
 x14 and x1: 0.9999479424242859 
 x14 and x4: 0.9438583563660174

코사인 유사도에 따르면 14번은 이번에는 1번과 가장 가까운 것으로 나온다. 1번은 adult 클래스로 우리가 기대하는 바와는 다소 다른 결과이며, 뿐만 아니라 유클리드 거리에서 가장 가까웠던 4번 인스턴스는 오히려 가장 먼 것으로 나온다.

무슨 일이 일어 났을까

유클리드 거리()와 코사인 유사도()를 시각적으로 표현하면 아래와 같다.

유클리드 거리는 줄자로 거리를 측정하는 것과 유사하며, 코사인 유사도는 무게나 크기는 전혀 고려하지 않고 벡터 사이의 각도만으로 측정하는 것과 유사하다.

즉, 14번과 4번은 줄자로 쟀을때는(유클리드 거리) 가장 가깝게 나오지만 각도를 쟀을때는(코사인 유사도) 가장 낮은 값이 되었던 것이다.

언제 코사인 유사도를 사용하는가

그렇다면 코사인 유사도는 언제 사용할까?

일반적으로 코사인 유사도는 벡터의 크기가 중요하지 않을때 거리를 측정하기 위한 메트릭으로 사용된다. 예를 들어 단어의 포함 여부로 문서의 유사 여부를 판단한다고 할때 ‘science’라는 단어가 2번 보다 1번 문서에 더 많이 포함되어 있다면 1번 문서가 과학 문서라고 추측할 수 있을 것이다. 그러나, 만약 1번 문서가 2번 문서 보다 훨씬 더 길다면 공정하지 않은 비교가 된다. 이때 코사인 유사도는 이 문제를 바로 잡아줄 수 있다.

즉, 길이를 정규화해 비교하는 것과 유사하다고 할 수 있으며 이 때문에 텍스트 데이터를 처리하는 메트릭으로 주로 사용된다. 주로 데이터 마이닝이나 정보 검색information retrieval에서 즐겨 사용된다.

코사인 유사도 예제

그렇다면 실제 예제를 살펴보면서 코사인 유사도가 어떤 역할을 하는지 살펴보자.

import wikipedia

q1 = wikipedia.page('Machine Learning')
q2 = wikipedia.page('Artifical Intelligence')
q3 = wikipedia.page('Soccer')
q4 = wikipedia.page('Tennis')

위키피디어에서 4개의 문서를 가져온다.

q1.content[:100]
--
'Machine learning is a field of computer science that often uses statistical techniques to give compu'

q1.content.split()[:10]
--
['Machine',
 'learning',
 'is',
 'a',
 'field',
 'of',
 'computer',
 'science',
 'that',
 'often']

print("ML \t", len(q1.content.split()), "\n"
      "AI \t", len(q2.content.split()), "\n"
      "soccer \t", len(q3.content.split()), "\n"
      "tennis \t", len(q4.content.split()))
--
ML 	 4048 
AI 	 13742 
soccer 	 6470 
tennis 	 9736

변수에는 각각의 본문이 들어가며, 문서의 길이는 모두 다르다.

from sklearn.feature_extraction.text import CountVectorizer

cv = CountVectorizer()
X = np.array(cv.fit_transform([q1.content, q2.content, q3.content, q4.content]).todense())

이를 k-hot vector로 인코딩 한다.

X[0].shape
--
(5484,)

X[0][:20]
--
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
      dtype=int64)

X는 전체 단어수 만큼의 배열이며, 각각의 값은 해당 단어의 출현 빈도를 나타낸다.

이제 이 문서들간의 유사도를 유클리드 거리로 나타내보자.

print("ML - AI \t", euclidean_distance(X[0], X[1]), "\n"
      "ML - soccer \t", euclidean_distance(X[0], X[2]), "\n"
      "ML - tennis \t", euclidean_distance(X[0], X[3]))
--
ML - AI 	 846.53411035823 
ML - soccer 	 479.75827246645787 
ML - tennis 	 789.7069076562519

ML 문서는 축구 문서와 가장 가깝고 AI 문서와는 가장 먼 것으로 나타난다. 일반적으로 우리가 생각하는 결과와 다르다. 이는 문서의 길이가 다르기 때문인데, 이번에는 코사인 유사도로 한 번 비교해보자.

print("ML - AI \t", cosine_similarity(X[0], X[1]), "\n"
      "ML - soccer \t", cosine_similarity(X[0], X[2]), "\n"
      "ML - tennis \t", cosine_similarity(X[0], X[3]))
--
ML - AI 	 0.8887965704386804 
ML - soccer 	 0.7839297821715802 
ML - tennis 	 0.7935675914311315

AI 문서가 가장 높은 값, 축구 문서가 가장 낮은 값으로 앞서 유클리드 거리와는 정반대의 결과이며 이는 우리의 직관과 일치하는 결과를 보여준다. 그렇다면 이번에는 문서의 길이를 정규화 해서 유클리드 거리로 다시 한 번 비교해보도록 한다.

def l2_normalize(v):
    norm = np.sqrt(np.sum(np.square(v)))
    return v / norm

print("ML - AI \t", 1 - euclidean_distance(l2_normalize(X[0]), l2_normalize(X[1])), "\n"
      "ML - soccer \t", 1 - euclidean_distance(l2_normalize(X[0]), l2_normalize(X[2])), "\n"
      "ML - tennis \t", 1 - euclidean_distance(l2_normalize(X[0]), l2_normalize(X[3])))
--
ML - AI 	 0.5283996828641448 
ML - soccer 	 0.3426261066509869 
ML - tennis 	 0.3574544240773757

코사인 유사도와 값은 다르지만 패턴은 일치한다. AI 문서가 가장 높은 값, 축구 문서가 가장 낮은 값으로 길이를 정규화해 유클리드 거리로 비교한 결과는 코사인 유사도와 거의 유사한 패턴을 보인다.

트위터 분류

또 다른 예제를 살펴보자. 오픈AI의 트윗이다.

ml_tweet = "New research release: overcoming many of Reinforcement Learning's limitations with Evolution Strategies."
x = np.array(cv.transform([ml_tweet]).todense())[0]

당연히 ML 또는 AI와 유사한 결과가 나와야 할 것 같다.

print("tweet - ML \t", euclidean_distance(x, X[0]), "\n"
      "tweet - AI \t", euclidean_distance(x, X[1]), "\n"
      "tweet - soccer \t", euclidean_distance(x, X[2]), "\n"
      "tweet - tennis \t", euclidean_distance(x, X[3]))
--
tweet - ML 	 373.09114167988497 
tweet - AI 	 1160.7269274036853 
tweet - soccer 	 712.600168397398 
tweet - tennis 	 1052.5796881946753

그러나 유클리드 거리로 계산한 결과는 축구 문서가 AI 문서보다 오히려 더 가까운 것으로 나온다. 이제 코사인 유사도 결과를 살펴보자.

print("tweet - ML \t", cosine_similarity(x, X[0]), "\n"
      "tweet - AI \t", cosine_similarity(x, X[1]), "\n"
      "tweet - soccer \t", cosine_similarity(x, X[2]), "\n"
      "tweet - tennis \t", cosine_similarity(x, X[3]))
--
tweet - ML 	 0.2613347291026786 
tweet - AI 	 0.19333084671126158 
tweet - soccer 	 0.1197543563241326 
tweet - tennis 	 0.11622680287651725

AI 문서가 축구 문서 보다 훨씬 더 유사한 값으로 나온다. 이번에는 길이를 정규화해 유클리드 거리로 비교한 결과는 어떨까.

print("tweet - ML \t", 1 - euclidean_distance(l2_normalize(x), l2_normalize(X[0])), "\n"
      "tweet - AI \t", 1 - euclidean_distance(l2_normalize(x), l2_normalize(X[1])), "\n"
      "tweet - soccer \t", 1 - euclidean_distance(l2_normalize(x), l2_normalize(X[2])), "\n"
      "tweet - tennis \t", 1 - euclidean_distance(l2_normalize(x), l2_normalize(X[3])))
--
tweet - ML 	 -0.2154548703241279 
tweet - AI 	 -0.2701725499228351 
tweet - soccer 	 -0.32683506410998 
tweet - tennis 	 -0.3294910282687

값이 작아서 음수로 나타나긴 했지만 마찬가지로 AI 문서가 축구 문서 보다 더 높은 값을 잘 나타내는 것을 확인할 수 있다.

so_tweet = "#LegendsDownUnder The Reds are out for the warm up at the @nibStadium. Not long now until kick-off in Perth."
x2 = np.array(cv.transform([so_tweet]).todense())[0]

이번에는 맨체스터 유나이티드의 트윗을 살펴보자.

print("tweet - ML \t", euclidean_distance(x2, X[0]), "\n"
      "tweet - AI \t", euclidean_distance(x2, X[1]), "\n"
      "tweet - soccer \t", euclidean_distance(x2, X[2]), "\n"
      "tweet - tennis \t", euclidean_distance(x2, X[3]))
--
tweet - ML 	 371.8669116767449 
tweet - AI 	 1159.1397672412072 
tweet - soccer 	 710.1035135809426 
tweet - tennis 	 1050.1485609188826

유클리드 거리는 ML 문서가 축구 문서보다 더 가깝다고 잘못 얘기한다.

print("tweet - ML \t", cosine_similarity(x2, X[0]), "\n"
      "tweet - AI \t", cosine_similarity(x2, X[1]), "\n"
      "tweet - soccer \t", cosine_similarity(x2, X[2]), "\n"
      "tweet - tennis \t", cosine_similarity(x2, X[3]))
--
tweet - ML 	 0.4396242958582417 
tweet - AI 	 0.46942065152331963 
tweet - soccer 	 0.6136116162795926 
tweet - tennis 	 0.5971160690477066

그러나 코사인 유사도는 축구 문서가 훨씬 더 유사하다.

print("tweet - ML \t", 1 - euclidean_distance(l2_normalize(x2), l2_normalize(X[0])), "\n"
      "tweet - AI \t", 1 - euclidean_distance(l2_normalize(x2), l2_normalize(X[1])), "\n"
      "tweet - soccer \t", 1 - euclidean_distance(l2_normalize(x2), l2_normalize(X[2])), "\n"
      "tweet - tennis \t", 1 - euclidean_distance(l2_normalize(x2), l2_normalize(X[3])))
--
tweet - ML 	 -0.0586554719470902 
tweet - AI 	 -0.030125573390623384 
tweet - soccer 	 0.12092277504145588 
tweet - tennis 	 0.10235426703816686

길이를 정규화한 유클리드 거리 계산 결과도 축구 문서가 가장 높은 동일한 패턴을 보이며 이는 우리의 직관과 일치하는 만족스런 결과를 보여준다.

참고

전체 코드를 포함한 주피터 노트북으로 직접 계산해본 결과는 euclidean-v-cosine.ipynb에서 확인할 수 있으며, 원문은 Euclidean vs. Cosine Distance에서 볼 수 있다.

is a collection of Papers I have written.
© 2000 - Sang-Kil Park Except where otherwise noted, content on this site is licensed under a CC BY-NC 4.0.
This site design was brought from Distill.