* 현재 진행중인 연재에 속한 문서입니다. 구조나 내용이 지속적으로 변경될 수 있습니다.

학교에서 들었던 수업 중 가장 기억에 남는 수업을 떠올려 보라고 하면, 2018년 초에 들었던 Musical Applications of Machine Learning 수업이 떠오른다. 이 수업은 Music Information Retrieval 분야에 대해 얕고 넓게 배우고, 마지막으로 배운 지식을 이용하여 직접 프로젝트를 수행해보는 구조로 되어있었다.

Music Information Retrieval을 간단하게 말하면, 시그널 프로세싱이나 머신러닝 등의 방법을 이용해 음원과 메타데이터로부터 유용한 정보를 뽑아내어 음악 추천, 자동 작곡 등 다양한 방면의 문제 해결에 응용하는 분야라고 할 수 있다.

올해 상반기에는 이 중에서 음악 추천, 특히 유사곡 추천을 위한 여러 가지 방법을 연재하려 한다. (목표는 주간연재!)

  1. 유사곡 추천
    1. 노래를 벡터로 표현하는 방법
  2. 유저 정보를 이용한 접근
    1. 교집합의 크기
    2. TF-IDF
    3. Latent Semantic Analysis
  3. 실습: Jupyter Notebooks
  4. 다음에 다룰 주제
  5. 참고한 자료
  6. Q & A
    1. 차원 축소를 할 경우, 축소된 공간의 기저(base)가 대중적인 노래 쪽으로 편향될 것 같아요.

유사곡 추천

유사곡을 추천하기 위해서는 두 곡이 주어졌을 때, 유사도를 계산하는 방법이 필요하다. 모든 노래 쌍에 대해 사람이 일일이 유사도를 매길 수는 없기 때문에, 거리를 측정하기 쉽도록 노래를 벡터 공간에 표현하는 접근이 많이 사용된다.

노래를 벡터로 표현하는 방법

노래를 벡터로 표현하는 방법에는 크게 두 가지 접근이 존재한다.

  1. 유저 정보를 이용한 접근 (Collaborative)
  2. 음원 정보를 이용한 접근 (Content-based)

일상에서는 첫 번째 방법을 이용하는 경우가 흔하다. 예를 들어 평소에 우리가 노래를 친구에게 추천받는다거나, 탑100 이나 빌보드 차트를 듣는 경우를 생각해보자. 그럼 이와 비슷하게 사람들의 취향을 이용하여 노래를 벡터로 표현하는 시도 역시 어렵지 않게 상상해볼 수 있다.

유저 정보를 이용한 접근

유저 정보에는 매우 다양하고 섬세한 데이터가 존재하지만, 여기서는 가장 측정하기 쉽고, 분명한 취향을 담고 있을 ‘플레이 카운트 행렬’ 을 이용하자. 이는 N명의 곡과 M개의 곡에 대해, 각 유저가 가장 많이 들은 몇몇 곡(T ≪ N)에 대한 재생 횟수를 나타내는 N × M 행렬이다. 다시말해 행렬의 각 행이 곡을 나타내고, 각 열이 유저를 나타낸다. 한편, N과 M이 큰 경우에도 행렬의 원소 중 대부분은 0이고 소수(약 M⋅T 개)의 원소만 값을 가지고 있으므로, 희소 행렬 데이터 구조를 사용하면 실제 값이 존재하는 칸에 대해서만 연산을 하게 되어 효율적이다.

>>> import numpy as np
>>> from dataset import load_play_count
>>> play_count_matrix = load_play_count()
>>> type(play_count_matrix)
scipy.sparse.csr.csr_matrix  # 희소 행렬, 값이 0이 아닌 칸만 저장한다.
>>> play_count_matrix.shape
(29959, 432754)

플레이 카운트 행렬에서 유사곡을 찾는 가장 직관적인 방법이 무엇일까? 간단하게 노래 i 를 많이 들은 사람이 노래 j를 많이 들었으면 노래 i 와 노래 j 는 비슷하다고 생각해볼 수 있다. 이와 비슷한 가정을 자연어 처리와 같은 분야에서는 분산 시맨틱스 가정(Distributional Hypothesis)이라고 부르는데, 간단히 말해 “유사한 의미를 갖는 단어 i 와 단어 j 는 비슷한 문맥에서 나타난다.” 라고 할 수 있다. 이 가정에 기반한 것 중 아마 가장 유명한 것으로는 몇년 전 세상을 흔들어 놓았던 Word2vec이 있다.

교집합의 크기

우선 복잡한 이야기를 하기 전에, 앞서 말한 아이디어를 약간 단순화 시켜서, 실제로 얼마나 말이 되는지 확인해보자. 노래 i 에 대해 다른 노래 j 들의 “두 노래를 모두 들어본 적이 있는 유저의 수, 즉 교집합의 크기” 를 계산해보자. 이는 플레이 카운트 행렬을 이진화(양수 값을 모두 1로 바꾼 형태)한 것을 X 라고 할 때, X⋅XT 를 수행하여 쉽게 계산할 수 있다.

# 이진화
>>> play_count_binary = play_count_matrix.copy()
>>> play_count_binary[play_count_binary > 0] = 1
# 각 행(즉 노래) 쌍의 교집합 크기를 계산한다.
>>> all_pair_intersections = (play_count_binary * play_count_binary.transpose())
>>> all_pair_intersections.shape
(29959, 29959)

이렇게 계산한 교집합의 크기에 따라 노래를 정렬해보면 이런 결과를 얻을 수 있다.

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

얼핏 보면 되는 것 같지만, 우주를 줄게와 같이 유명한 곡은 전혀 관계 없어 보이는 곡에도 상위권에 자주 나타나는 한계가 보인다. 사실 이런 현상은 자카드 지수와 같은 방법으로 어느 정도 완화할 수 있지만, 우선은 넘어가자.

여담으로, 노래의 인기도를 그래프로 그려보면 그야말로 정석 그 자체의 롱테일 분포를 볼 수 있다.

TF-IDF

이런 문제를 해결하기 위해 정보 검색 분야에서는 단어 빈도-역문서 빈도(Term Frequency-Inverse Document Frequency)를 고안했다. TF를 통해 각 단어가 문서에서 얼마나 큰 비중을 차지하고 있는지를 계산하되, IDF를 통해 너무 자주 쓰여 어디에나 나오는 단어, 이를테면 the 와 같은 단어에 패널티를 주어 영향을 완화하는 방법이다. TF-IDF를 구현하는 방법에는 여러 가지가 있지만, 여기서는 Lucene에서 사용하는 방법을 따르기로 한다.

>>> def tf_idf(matrix):
...     tf = matrix.sqrt()
...     idf = 1 + np.log1p(matrix.shape[-1]) - np.log1p(np.bincount(matrix.tocoo().row))
...     return tf.multiply(idf.reshape(-1, 1))
>>> play_count_tfidf = tf_idf(play_count_matrix)

이번에 얻은 행렬은 아까처럼 각 행 쌍을 비교하려 해도, 마땅히 비교할 방법이 떠오르지 않는다. 이럴 때, 벡터 사이의 유사도를 구하는 방법에는 여러 가지가 있지만, 아까같은 무의미한 추천을 피하려면 벡터의 크기(위의 tf 부분을 보시라)에 영향을 받지 않는 방법을 이용해야 하므로 코사인 유사도를 이용하자. 코사인 유사도는 두 벡터의 방향이 얼마나 일치하는지를 -1 ~ 1의 실수로 나타내는 방법으로, 각도가 완전히 일치하면 1, 수직이면 0, 완전히 반대이면 -1이 나온다. 여기서 플레이 카운트를 이용하여 만든 벡터의 경우, 음수 값이 없기 때문에 0과 1 사이의 수가 나오게 된다.

>>> def cosine_similarity_sparse(v1, v2):  # sparse vector 에 대한 연산
...     return v1.dot(v2.transpose())[0, 0] / np.sqrt(v1.dot(v1.transpose())[0, 0]) / np.sqrt(v2.dot(v2.transpose())[0, 0])
>>> cosine_similarity_sparse(play_count_tfidf[0], play_count_tfidf[1])
0.006587908852572203

TF-IDF를 플레이 카운트 행렬에 적용한 결과를 살펴보자. 유저가 들은 노래 중 큰 비중을 차지하는 노래를 강조하되 유명도로 인한 치우침의 영향이 줄어든 것을 볼 수 있다. (*벡터가 커서 계산이 조금 오래 걸립니다)

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

우주를 줄게처럼 유명한 노래는 여전히 개선되질 않았지만(그저 차트에서 유사한 순위에 위치한 노래가 나온다), 인생의 회전목마는 확실히 나아졌다. 하지만 약간이라도 마이너한 노래의 경우에는 가장 가까운 노래(결과의 맨 위)에 뜬금없는 곡이 나오고, 나머지도 조금 고개를 갸우뚱하게 만드는 결과를 보인다.

Latent Semantic Analysis

플레이 카운트의 TF-IDF 표현보다 덜 민감한, 조금 더 일반적인 표현을 얻을 방법은 없을까? IR 분야에서는 이런 고차원의 희소 데이터를 다루기 위한 기초적인 방법으로 Latent Semantic Analysis를 사용한다. LSA를 간단하게 말하면, TF-IDF 등의 가중치로 처리된 행렬을 손실 압축하여 유사도 분석이나 검색과 같은 분야에 이용하는 방법이다.

사실 손실 압축이란 표현은 좋지 않은데, LSA는 데이터에 잠재된(latent) 의미적(semantic) 속성을 나타내는 저차원의 벡터를 얻어내려는 의도에서 고안됐다. 실제로 각 행이 단어, 각 열이 문서를 나타내고 단어의 문서 내 등장 횟수를 각 칸의 원소로 하는 행렬을 LSA로 분석하여 단어의 저차원 ‘의미 벡터(semantic vector)’ 표현을 얻으면, 단어간의 의미적 유사도를 어느 정도 잡아내는 것을 확인할 수 있다. (Word2vec 만큼은 아니지만) 여기서는 이 방법을 그대로 플레이 횟수 행렬에 적용해서, 각 음악의 ‘취향 벡터’를 얻으려 한다.

정리하면, 현재 우리가 가지고 있는 플레이 카운트 행렬은 N × M 의 크기를 가지고 있다. 이를 K ≪ M 인 K에 대해 N × K 크기의 행렬로 근사하여 각 노래를 K 차원의 벡터로 표현해보자. 이렇게 저차원의 행렬을 이용하여 데이터를 근사하는 접근을 Low-rank Approximation이라고 하는데, 여기서는 이런 방법 중 가장 흔하게 쓰이는 방법인 특잇값 분해(Singular Value Decomposition)를 이용한다.

>>> from sklearn.decomposition import TruncatedSVD
>>> def svd(matrix, K):
...     transformer = TruncatedSVD(n_components=K, n_iter=10)
...     return transformer.fit_transform(matrix)
>>> play_count_lsa = svd(play_count_tfidf, K=128)
>>> play_count_lsa.shape
(29959, 128)

SVD를 이용하면 행렬 X 에 대해 ||X - Reconstruct(A)||2 를 최소화 하는 저차원(K차원)의 행렬 A 를 빠르게 계산 가능하다. 이번에도 코사인 유사도를 이용해서 벡터의 유사도를 계산해 보자.

>>> def cosine_similarity_dense(v1, v2):  # 이번엔 dense vector 이다.
...     return v1.dot(v2.transpose()) / np.sqrt(v1.dot(v1.transpose())) / np.sqrt(v2.dot(v2.transpose()))
>>> cosine_similarity_dense(play_count_lsa[0], play_count_lsa[1])
0.6833129281229293  # tf-idf 때와 값이 큰 차이를 보인다!

결과적으로 ‘플레이 카운트 행렬 -> TF-IDF 가중치 -> SVD 저차원 근사’ 를 통해 K 차원의 실수 값으로 구성된 벡터 표현을 얻었다. 이를 통해 유사곡을 찾아보면 아까보다 훨씬 상식적으로 보이는 결과가 나온다.

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

자세히 비교해 보면, 이번에는 날 사랑하는게 아니고의 경우에도 유난히 이상한 결과 없이 괜찮게 나온다. (이를테면 언니네 이발관이 상위권에 위치한다거나 하는 점이) 다만 여전히 우주를 줄게처럼 인기가 많은 곡은 여전히 대부분 유사한 순위의 노래만 추천되는 문제를 가지고 있지만, 적어도 1순위는 한동근이 아닌 볼빨간 사춘기의 노래가 나오는 것에서 가능성을 발견할 수 있다.

이렇게 LSA를 통해 얻은 ‘취향 벡터’는 적은 차원 수를 가져 연산 속도와 데이터의 크기도 줄이면서 추천 퀄리티도 훨씬 좋은데, 이로부터 플레이 카운트 행렬이 필요 이상으로 크고 복잡했던 것을 알 수 있고, 그런 큰 데이터 안에 숨어 있는 취향을 찾아내어 저차원의 벡터 임베딩으로 나타내는 접근이 유망함을 알 수 있다.

실습: Jupyter Notebooks

백 마디 말보다 직접 해 보는게 낫습니다. GitHub 저장소에 업로드 해 둔 노트북을 이용해서 직접 실험을 해 보세요!

다음에 다룰 주제

  • 다음에는 Implicit Matrix Factorization을 이용해서 취향의 벡터 임베딩을 얻는 방법에 대해 알아볼 예정입니다.
  • 그 이후에는 아마 두 번째 방법으로 소개한, 음원 데이터를 이용한 접근에 대해 알아볼 예정입니다. (딥 러닝!)
  • 다음 연재가 언제 올라오는지 알림을 받고싶다면 GitHub 저장소를 watch 하시면 됩니다 :)

참고한 자료

Q & A

차원 축소를 할 경우, 축소된 공간의 기저(base)가 대중적인 노래 쪽으로 편향될 것 같아요.

이를 이해하기 위해 특잇값 분해를 이용한 차원 축소의 특징에 대해 살펴봅시다. 특잇값 분해를 수행해서 D차원의 벡터로 이루어진 데이터(N×D) 를 K차원으로(N×K) 줄일 경우, K개의 기저를 선택하는 기준은 바로 해당하는 기저에 대해 데이터가 퍼져있는 정도, 즉 분산(variance)입니다. 즉, 직관적으로 생각해 보면, 특징이 확연히 나뉘는 부류(예: 7080과 최신 힙합)를 나누는 기저가 우선적으로 선택될 것이고, 비슷한 부류 안에서의 미세한 차이를 나타내는 기저는 선택되지 않을 가능성이 높습니다. 물론 실제로 특잇값 분해를 통해 얻어지는 K개의 기저는 원본 데이터의 기저를 적절히 선형 결합한 것들이므로 원래 데이터에 존재하던 어느 한 기저(이 경우엔 유저)가 아예 사라지지는 않지만, 사라졌다고 해도 될 정도로 영향이 줄어든다고 볼 수 있겠습니다.

다시 정리해 보면, 굳이 마이너한 장르라고 해서 구분이 불가능해 진다기 보단, 취향이 확실히 갈리는 부류 간의 분포에 비해 비슷한 취향 내의 분포는 말씀하신 대로 가까워 질 가능성이 높아 보입니다. 오히려 조금 극단적으로, 마이너한 노래를 듣는 부류와 메이저한 노래를 듣는 노래를 듣는 부류가 확연히 다르다면, 그 둘을 나누는 기저가 선택될 가능성이 높다고 볼 수도 있습니다. (물론 실제로는 훨씬 복잡하지만) 다만 차원의 수가 줄어들어 거리가 압축되는 것이 나쁜 것만은 아닌데, 이전의 벡터가 지금처럼 너무 희소한 고차원의 형태를 나타낼 경우에는 비슷한 취향의 노래 끼리도 거리가 너무 멀어서(왼쪽) 거리가 무의미한 경우가 있기 때문에, 서로 가까워지는 것이 오히려 데이터의 의미를 잘 나타낼 수도 있음을 확인할 수 있습니다.

오지은과 몇몇 가수 사이의 거리 (1 - cosine; 값이 클 수록 멂)
TF-IDF LSA (TF-IDF+SVD)