코사인 유사도가 무엇인지를 따지지 전에 먼저 따져야 할 것이 있다. 코사인 유사도를 구하려면, 해당 관찰값의 속성(feature)을 벡터로 표현해야 한다. 차원이 같은 두 개의 벡터가 있을 때 이 벡터들 사이의 각도의 코사인을 값이 코사인 유사도다.
코사인 값은 무엇을 의미할까? 두 벡터가 비슷한 방향을 향하고 있다면 1에 가까운 값이 된다. 자기 자신에 대한 코사인 유사도는 1이다. 반면 두 벡터가 큰 상관이 없다면 0이다. 반대의 지향을 갖고 있다면 -1에 가깝게 된다.
코사인 유사도는 두 벡터로 잴 수 있는 가장 기본적인 지표인 (유클리드) ’거리’와 어떻게 다를까? 거리의 경우는 둘 사이가 얼마나 떨어져 있는지를 측정한다. 2차원 벡터를 생각해보자. \((10, 10)\)과 \((100,100)\)이 있다고 하자. 두 벡터는 같은 지향을 지니고 있기 때문에 코사인 유사도는 1이다. 반면, 거리는 \(\sqrt{(100-10)^2 + (100-10)^2}\)이다. 즉, 코사인 유사도는 해당 벡터의 절대 위치보다는 어느 방향을 향하고 있는지를 측정한다.
닷프로덕트의 정의를 활용하면 \(\cos \theta\)를 다음과 같이 정의할 수 있다. 차원에 동일한 두 개의 벡터 \(\mathbf a\), \(\mathbf b\)가 있다고 하자.
\[\begin{align} \cos \theta = \dfrac{\mathbf a \cdot \mathbf b}{\lVert \mathbf a \rVert \lVert \mathbf b \rVert} \end{align}\]
요약하면 두 벡터의 길이(norm)와 닷프로덕트만 계산하면 코사인 값을 얻을 수 있다.
코사인 유사도를 살필 수 있는 좋은 사례가 없을까, 찾다가 좋은 튜토리얼을 발견했다. 이 튜토리얼은 사용자별 플레이 기록을 통해 추천곡 목록을 생성하는 내용을 담고 있다. 여기서 추천곡이란 어떤 특정한 노래와 비슷한 노래들을 지칭한다. 비슷함의 정의를 코사인 유사도로 두는 것이 핵심이다. 앞서 말했지만 코사인 유사도를 계산하라면 관심 대상을 벡터로 나타내야 한다. 튜토리얼에서는 다음과 같이 벡터를 생성했다.
\[\begin{align} s_i = [0, 0, \dotsc, \underset{\text{$u_j$의 청취횟수}}{5}, \dotsc] \end{align}\]
이용자의 집합 \(U = \left\{ u_1, u_2, \dotsc, u_n \right\}\)와 노래 집합 \(S = \left\{ s_1, s_2, \dotsc, s_m \right\}\)이 있다고 하자. \(U\)에 속하는 각 플레이어의 청취 횟수를 노래(\(s_j\)) 벡터로 만든다. 만일 해당 플레이어의 \(s_i\) 청취 횟수가 없다면 0이 될 것이다. \(S\) 벡터 공간의 차원은 \(m\)이다. 두 노래 벡터 \(s_i, s_j \in S\)가 주어지면 코사인 유사도를 쉽게 측정할 수 있다. 해당 튜토리얼에 이를 진행하는 R 코드가 있고 이를 반복 해설하지 않다. 이 글에서는 같은 코사인 유사도를 측정하는 다른 방식을 제시해보겠다.
R의 데이터 다루기의 기본기로 자리잡아 가는 Tidyverse에 기반해서 코사인 유사도 계산을 다시 해보자. 우선, 위의 튜토리얼처럼 순진한 방식으로 계산을 해보기 바란다. 데이터를 다루는 관점에서 튜토리얼의 관점은 몇 가지 문제가 있다. 우선 노래가 매트릭스의 컬럼으로 가 있는데, 만일 노래를 1,000개에서 끊지 않는다면 매트릭스의 사이즈가 문제가 될 수 있다. 어차피 노래별로 그루핑해서 계산하는 것이라면 dplyr 패키지의 group_by
명령을 활용해 보다 쉽게 해결할 수 있지 않을까? 우선 이를 위해서 플레이 횟수가 많은 천곡의 리스트를 가져오겠다.
user | song_id | plays | title | artist_name |
---|---|---|---|---|
00003a4459f33b92906be11abe0e93efc423c0ff | SOVMGXI12AF72A80B0 | 1 | Hey Mama | Black Eyed Peas |
00003a4459f33b92906be11abe0e93efc423c0ff | SOWVBDQ12A8C13503D | 3 | Volveré | Gian Marco |
00030033e3a2f904a48ec1dd53019c9969b6ef1f | SOBONKR12A58A7A7E0 | 2 | You’re The One | Dwight Yoakam |
00030033e3a2f904a48ec1dd53019c9969b6ef1f | SONTQUO12A6D4F7D8B | 4 | Chasing Cars | Snow Patrol |
00030033e3a2f904a48ec1dd53019c9969b6ef1f | SONYKOW12AB01849C9 | 7 | Secrets | OneRepublic |
00030033e3a2f904a48ec1dd53019c9969b6ef1f | SOXAOAJ12AB0184578 | 3 | 21 Guns [feat. Green Day & The Cast Of American Idiot] (Album Version) | Green Day |
보는 바와 같이 우리가 이용할 데이터는 일종의 key에 해당하는 user, song_id 그리고 해당 곡의 플레이 횟수, 이름, 아티스트의 제목으로 이루어져 있다. 코사인 유사도 계산을 위한 전략은 세가지로 나뉜다.
이를 계산하는 함수 calc_cos_sim
는 다음과 같다.
calc_cos_sim <- function(vec_x, vec_y){
vec_x %>% ungroup() %>% select(user, plays) -> vec_x
vec_y %>% ungroup() %>% select(user, plays) -> vec_y
vec_x %>%
full_join(vec_y, by = 'user') %>%
replace_na(list(plays.x = 0, plays.y = 0)) %>%
mutate(
prod = plays.x * plays.y
) %>%
summarise(
norm.x = sqrt(sum(plays.x^2)),
norm.y = sqrt(sum(plays.y^2)),
dot_prod = sum(prod)
) %>%
mutate(
cos_sim = dot_prod / (norm.x * norm.y)
)
}
두 개의 벡터를 받아서 해당 각각 벡터의 길이와 닷프로덕트를 계산하고 이를 통해 코사인 유사도를 계산하는 함수다. 주의해야 할 점이 있다. 앞서 말했듯이 코사인 유사도 계산에 들어가는 두 벡터의 차원이 동일해야 한다. 벡터의 길이는 대상 이용자 전체의 숫자가 되어야 한다. 그런데, 위의 코드를 보면 전체 이용자 보다는 닷 프로덕트를 구하는 두 개의 벡터 중 차원이 큰 것이 맞춰지게 되어 있다. 이는 계산 상의 편의를 위한 것이다. 어차피 비교가 되는 두 벡터 모두에서 제외된 차원은 벡터 값이 0이 되므로 곱해도 0이 된다. 편리한 계산을 위한 트릭 정도로 이해하면 좋겠다.
만일 \(s_i\)에 속한 이용자와 \(s_j\)에 속한 이용자가 다르다면 둘의 합집합을 취해야 한다. 이때 각각의 노래에 속하지 않은 이용자의 재생 횟수는 0으로 처리하면 된다. 위 코드에서 full_join
과 replace_na
가 이를 처리해주고 있다.
이 처리는 함수 generate_song_list_by_cos_sim
내부에서 수행된다. 즉, 적당한 tibble과 타겟 song_id가 주어져 있을 때 song_id
별로 그루핑을 한 뒤 이 그룹에 대해서 위에서 정의한 calc_cos_sim
을 일괄적으로 적용한다.
generate_song_list_by_cos_sim("SODEOCO12A6701E922", all_data_top_1k) %>%
ungroup() %>%
select(-c(song_id, norm.x, norm.y, dot_prod)) %>%
head(10) %>%
knitr::kable()
cos_sim | title | artist_name |
---|---|---|
1.0000000 | Come As You Are | Nirvana |
0.3903533 | The Man Who Sold The World | Nirvana |
0.3568732 | Lithium | Nirvana |
0.1958162 | Heart Shaped Box | Nirvana |
0.1186160 | Behind Blue Eyes | Limp Bizkit |
0.0952245 | Fakty | Horkyze Slyze |
0.0851531 | In My Place | Coldplay |
0.0763979 | When You Were Young | The Killers |
0.0692723 | The Middle | Jimmy Eat World |
0.0667578 | Flashing Lights | Kanye West |
코사인 유사도가 제대로 계산되고 있는지 확인해볼 요량으로 일부러 타겟 노래를 빼지 않았다. 자기 자신에 대한 유사도는 1로 가장 높은 값이 나온다. 대체로 너바나의 곡들이 상위에 있음을 알 수 있다.
generate_song_list_by_cos_sim("SOIHUUT12AF72A2188", all_data_top_1k) %>%
ungroup() %>%
select(-c(song_id, norm.x, norm.y, dot_prod)) %>%
head(10) %>%
knitr::kable()
cos_sim | title | artist_name |
---|---|---|
1.0000000 | With Or Without You | U2 |
0.1428010 | Blood | El-P |
0.1180037 | Alejate De Mi | Camila |
0.0670201 | Esta Es Para Hacerte Féliz | Jorge Gonzalez |
0.0508873 | More Than Words | Extreme |
0.0487711 | Un Dia Gris | Paulina Rubio |
0.0483759 | Mamma | Bram Vermeulen |
0.0483152 | Hey Daddy (Daddy’s Home) | Usher |
0.0458394 | Marry Me | Train |
0.0445650 | No One | Alicia Keys |
U2의 “With or Without You”와 비슷한 곡을 나열해보았다. 일단 코사인 유사도가 인정할 만큼 높은 노래가 없다. 그나마 비슷하게 선택된 노래들 역시 그리 비슷하지 않은 듯 싶다. 역시 U2의 이 노래는 위대한 곡이었다!