搖滾樂經過幾十年的發展,風格流派眾多,從blues,到brit invasion,之后是punk,disco,indie rock等等。發展歷程大致是這樣的:
搖滾樂的聽眾,總是能體會到發現寶藏的快樂,可能突然就會邂逅某支自己不曾接觸過的歌曲、樂隊、風格,感覺好聽得不行,以前怎么從來不知道,接下來的一段時間便會沉浸于此,每天都在聽該風格的主要樂隊和專輯。用戶收聽音樂在一段時間內可能是有著某個“主題”的,這個主題可能是地理上的(俄羅斯的搖滾樂隊),可能是時間上的(2000年后優秀的專輯),還可能是某流派、甚至是都被某影視作品用作BGM。之前很少聽國內搖滾的筆者,在去年聽了刺猬、P.K.14、重塑雕像的權利、新褲子、海朋森等一些國內樂隊的很多作品后,才知道原來在老崔、竇唯、萬青、老謝之外還有這么多優秀的國產搖滾樂。
這種“在某一時間會被用戶放到一起聽”的co-occurrence歌曲列表在音樂軟件里的形態是playlist或radio,由editor或用戶編輯生成,當然,還有”專輯“這個很強的聯系,特別是像《The Dark Side of the Moon》這樣的專輯。然而在前幾篇文章提到的內容中,最為核心的數據結構是用戶物品關系矩陣,這里面并沒有包含”一段時間“這個信息。這段時間可以稱為session,在其他領域的實際應用中,這個session可能是一篇研究石墨烯的論文,可能是一個Airbnb用戶某天在30分鐘內尋找夏威夷租房信息的點擊情況。把session內的co-occurrence關系考慮進去,可以為用戶做出更符合其當下所處情境的推薦結果。
這篇文章使用Word2vec處理Last.fm 1K數據集,來完成這種納入session信息的歌曲co-occurrence關系的建立。
Word2vec與音樂推薦
Word2vec最初被提出是為了在自然語言處理(NLP)中用一個低維稠密向量來表示一個word(該向量稱為embedding),并進一步根據embedding來研究詞語之間的關系。它使用一個僅包含一層隱藏層的神經網絡來訓練被分成許多句子的數據,來學習詞匯之間的co-occurrence關系,其中訓練時分為CBOW(Continuous Bag-of-Words)與Skip-gram兩種方式,這里簡單說一下使用Skip-gram獲取embedding的過程。
假設拿到了一些句子作為數據集,要為該神經網絡生成訓練樣本,這里要定義一個窗口大小比如為2,則對"shine on you crazy diamond"這句話來講,將窗口從左滑到右,按照下圖方式生成一系列單詞對兒,其中每個單詞對兒即作為一個訓練樣本,單詞對兒中的第一個單詞為輸入,第二個單詞為label。
假設語料庫中有10000個互不相同的word,首先將某個單詞使用one-hot vector(10000維)來表示輸入神經網絡,輸出同樣為10000維的vector,每一維上的數字代表此位置為1所代表的one-hot vector所對應的word在輸入word周圍的可能性:
輸入輸出層的節點數為語料庫word數,隱藏層的節點數則為表示每個單詞的向量的維數。此模型每個輸入層節點會與隱藏層的每個節點相連且都對應了一個權重,而對某輸入節點來說,它與隱藏層相連的所有這些權重組成的向量即為該節點為1所代表的one-hot vector所對應的單詞的embedding向量。
但該模型其實并不了解語義,它擁有的只是統計學知識,那么既然可以根據one-hot vector來標識一個word,當然可以用這種形式來標識一首歌曲,一支樂隊,一件商品,一間出租屋等等任何可以被推薦的東西,再把這些數據喂給模型,同樣的訓練過程,便可以獲取到各種物品embedding,然后研究它們之間的關系,此謂Item2vec,萬物皆可embedding。
故Word2vec與音樂推薦的關系就是,把一個歌單或者某個user在一個下午連續收聽的歌曲當作一句話(session),把每首歌當作一個獨立的word,然后把這樣的數據交給此模型去訓練即可獲取每首歌的embedding向量,這里從歌單到一句話的抽象,即實現了上文中提到的考慮進去“一段時間”這個點。
加載數據
該數據集包含了1K用戶對960K歌曲的收聽情況,文件1915萬行,2.4G,每行記錄了某用戶在某時間播放了某歌曲的信息。依然是用pandas把數據加載進來,這次需要timestamp的信息。
import arrow
from tqdm import tqdm
import pandas as pd
import numpy as np
from scipy.sparse import csr_matrix, diags
df = pd.read_csv('~/music-recommend/dataset/lastfm-dataset-1K/userid-timestamp-artid-artname-traid-traname.tsv',
sep = '\t',
header = None,
names = ['user_id', 'timestamp', 'artist_id', 'artist_name', 'track_id', 'track_name'],
usecols = ['user_id', 'timestamp', 'track_id', 'artist_name', 'track_name'],
)
df = df.dropna()
print (df.info())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 16936136 entries, 10 to 19098861
Data columns (total 5 columns):
user_id object
timestamp object
artist_name object
track_id object
track_name object
dtypes: object(5)
memory usage: 775.3+ MB
接下來做一些輔助的數據,為每個user、每首track都生成一個用于標識自己的index,建立從index到id,從id到index的雙向查詢dict。
df['user_id'] = df['user_id'].astype('category')
df['track_id'] = df['track_id'].astype('category')
user_index_to_user_id_dict = df['user_id'].cat.categories # use it like a dict.
user_id_to_user_index_dict = dict()
for index, i in enumerate(df['user_id'].cat.categories):
user_id_to_user_index_dict[i] = index
track_index_to_track_id_dict = df['track_id'].cat.categories # use it like a dict.
track_id_to_track_index_dict = dict()
for index, i in enumerate(df['track_id'].cat.categories):
track_id_to_track_index_dict[i] = index
song_info_df = df[['artist_name', 'track_name', 'track_id']].drop_duplicates()
考慮到專輯翻唱、同名、專輯重新發行等情況,需要用track_id來作為一首歌的唯一標識,而當需要通過artist_name
,track_name
來定位到一首歌時,這里寫了一個函數,采取的策略是找到被播放最多的那一個。
def get_hot_track_id_by_artist_name_and_track_name(artist_name, track_name):
track = song_info_df[(song_info_df['artist_name'] == artist_name) & (song_info_df['track_name'] == track_name)]
max_listened = 0
hotest_row_index = 0
for i in range(track.shape[0]):
row = track.iloc[i]
track_id = row['track_id']
listened_count = df[df['track_id'] == track_id].shape[0]
if listened_count > max_listened:
max_listened = listened_count
hotest_row_index = i
return track.iloc[hotest_row_index]['track_id']
print ('wish you were here tracks:')
print (song_info_df[(song_info_df['artist_name'] == 'Pink Floyd') & (song_info_df['track_name'] == 'Wish You Were Here')][['track_id']])
print ('--------')
print ('hotest one:')
print (get_hot_track_id_by_artist_name_and_track_name('Pink Floyd', 'Wish You Were Here'))
wish you were here tracks:
track_id
60969 feecff58-8ee2-4a7f-ac23-dc8ce7925286
4401932 f479e316-56b4-4221-acd9-eed1a0711861
17332322 2210ba38-79af-4881-97ae-4ce8f32322c3
--------
hotest one:
feecff58-8ee2-4a7f-ac23-dc8ce7925286
生成sentences文件
加載過數據后接下來要生成在科普環節提到的由歌名歌單生成句子,由于懶,沒有去爬云音樂的歌單數據,這里粗暴地將每個用戶每一天收聽的所有歌曲作為一個session,使用上文生成的track_index
來標識各歌曲,將生成的sentences寫到磁盤上。
def generate_sentence_file(df):
with open('sentences.txt', 'w') as sentences:
for user_index in tqdm(range(len(user_index_to_user_id_dict))):
user_id = user_index_to_user_id_dict[user_index]
user_df = df[df['user_id'] == user_id].sort_values('timestamp')
session = list()
last_time = None
for index, row in user_df.iterrows():
this_time = row['timestamp']
track_index = track_id_to_track_index_dict[row['track_id']]
if arrow.get(this_time).date() != arrow.get(last_time).date() and last_time != None:
sentences.write(' '.join([str(_id) for _id in session]) + '\n')
session = list()
session.append(track_index)
last_time = this_time
generate_sentence_file(df)
100%|██████████| 992/992 [1:22:23<00:00, 5.62s/it]
生成后的文件長這個樣子:
訓練模型生成embedding
有很多種方式可以獲取、實現Word2vec的代碼,可以用Tensorflow、Keras基于神經網絡寫一個,亦可以使用Google放到Google Code上的Word2vec實現,也可以在Github上找到gensim這個優秀的庫使用其已經封裝好的實現。
下列代碼使用smart_open
來逐行讀取之前生成的sentences.txt文件,對內存很是友好。這里使用50維的向量來代表一首歌曲,將收聽總次數不到20次的冷門歌曲篩選出去,設窗口大小為5。
from smart_open import smart_open
from gensim.models import Word2Vec
import logging
logging.basicConfig()
logging.getLogger().setLevel(logging.INFO)
class LastfmSentences(object):
def __init__(self, file_location):
self.file_location = file_location
def __iter__(self):
for line in smart_open(self.file_location, 'r'):
yield line.split()
lastfm_sentences = LastfmSentences('./sentences.txt')
model = Word2Vec(lastfm_sentences, size=50, min_count=20, window=10, hs=0, negative=20, workers=4, sg=1, sample=1e-5)
假如訓練的數據集為歌單,一個歌單為一個句子,由于出現在同一個歌單內代表了其中歌曲的某種共性,那么會希望將所有item兩兩之間的關系都考慮進去,故window size的取值可以取(所有歌單長度最大值-1)/2
,會取得更好的效果。這里由于是以用戶和天做分割,暫且拍腦袋拍出一個10。
sample用于控制對熱門詞匯的采樣比例,降低太過熱門的詞匯對整個模型的影響,比如Radiohead的creep,這里面還有個計算公式不再細說。
sg取0、1分別表示使用CBOW與Skip-gram算法,而hs取0、1分別表示使用hierarchical softmax與negative sampling。
關于negative sampling值得多說兩句,在神經網絡的訓練過程中需要根據梯度下降去調整節點之間的weight,可由于要調的weight數量巨大,在這個例子里為2*50*960000
,效率會很低下,處理方法使用負采樣,僅選取此訓練樣本的label為正例,其他隨機選取5到20個(經驗數值)單詞為反例,僅調整與這幾個word對應的weight,會使效率獲取明顯提升,并且效果也很良好。隨機選取的反例的規則亦與單詞出現頻率有關,出現頻次越多的單詞,越有可能會被選中為反例。
利用embedding
現在已經用大量數據為各track生成了與自己對應的低維向量,比如Wish You Were Here這首歌,這個embedding可以作為該歌曲的標識用于其他機器學習任務比如learn to rank:
model.wv[str(track_id_to_track_index_dict[
get_hot_track_id_by_artist_name_and_track_name(
'Pink Floyd', 'Wish You Were Here')])]
array([-0.39100856, 0.28636533, 0.11853614, -0.41582254, 0.09754885,
0.59501815, -0.07997745, -0.28060785, -0.0384276 , -0.84899545,
0.03777567, -0.00727402, 0.6960302 , 0.44756493, -0.13245133,
-0.38473454, -0.07809031, 0.34377965, -0.19210865, -0.33457756,
-0.36364776, -0.06028108, 0.17379969, 0.46617758, -0.04116876,
0.07322323, 0.11769405, 0.42464802, 0.25167897, -0.35790011,
0.01991512, -0.10950506, 0.26131895, -0.76148427, 0.48405901,
0.61935854, -0.59583783, 0.28353232, -0.14503367, 0.3232002 ,
1.00872386, -0.10348291, -0.0485305 , 0.21677236, -1.33224928,
0.57913464, -0.06729769, -0.32185984, -0.02978219, -0.43034038], dtype=float32)
這些embedding vector之間的相似度可以表示兩首歌出現在同一session內的可能性大小:
shine_on_part_1 = str(track_id_to_track_index_dict[
get_hot_track_id_by_artist_name_and_track_name('Pink Floyd', 'Shine On You Crazy Diamond (Parts I-V)')])
shine_on_part_2 = str(track_id_to_track_index_dict[
get_hot_track_id_by_artist_name_and_track_name('Pink Floyd', 'Shine On You Crazy Diamond (Parts Vi-Ix)')])
good_times = str(track_id_to_track_index_dict[
get_hot_track_id_by_artist_name_and_track_name('Chic', 'Good Times')])
print ('similarity between shine on part 1, 2:', model.wv.similarity(shine_on_part_1, shine_on_part_2))
print ('similarity between shine on part 1, good times:', model.wv.similarity(shine_on_part_1, good_times))
similarity between shine on part 1, 2: 0.927217
similarity between shine on part 1, good times: 0.425195
稍微看下源碼便會發現上述similarity函數,gensim也是使用余弦相似度來計算的,同樣可以根據該相似度,來生成一些推薦列表,當然不可能去遍歷,gensim內部也是使用上篇文章提到的Annoy來構建索引來快速尋找近鄰的。為了使用方便寫了如下兩個包裝函數。
def recommend_with_playlist(playlist, topn=25):
if not isinstance(playlist, list):
playlist = [playlist]
playlist_indexes = [str(track_id_to_track_index_dict[track_id]) for track_id in playlist]
similar_song_indexes = model.wv.most_similar(positive=playlist_indexes, topn=topn)
return [track_index_to_track_id_dict[int(track[0])] for track in similar_song_indexes]
def display_track_info(track_ids):
track_info = {
'track_name': [],
'artist_name': [],
}
for track_id in track_ids:
track = song_info_df[song_info_df['track_id'] == track_id].iloc[0]
track_info['track_name'].append(track['track_name'])
track_info['artist_name'].append(track['artist_name'])
print (pd.DataFrame(track_info))
接下來假裝自己在聽后朋,提供幾首歌曲,看看模型會給我們推薦什么:
# post punk.
guerbai_playlist = [
('Joy Division', 'Disorder'),
('Echo & The Bunnymen', 'The Killing Moon'),
('The Names', 'Discovery'),
('The Cure', 'Lullaby'),
]
display_track_info(recommend_with_playlist([
get_hot_track_id_by_artist_name_and_track_name(track[0], track[1])
for track in guerbai_playlist], 20))
track_name artist_name
0 Miss The Girl The Creatures
1 Splintered In Her Head The Cure
2 Return Of The Roughnecks The Chameleons
3 P.S. Goodbye The Chameleons
4 Chelsea Girl Simple Minds
5 23 Minutes Over Brussels Suicide
6 Not Even Sometimes The Prids
7 Windows A Flock Of Seagulls
8 Ride The Friendly Skies Lightning Bolt
9 Inmost Light Double Leopards
10 Thin Radiance Sunroof!
11 You As The Colorant The Prids
12 Love Will Tear Us Apart Boy Division
13 Slip Away Ultravox
14 Street Dude Black Dice
15 Touch Defiles Death In June
16 All My Colours (Zimbo) Echo & The Bunnymen
17 Summernight The Cold
18 Pornography (Live) The Cure
19 Me, I Disconnect From You Gary Numan
好多樂隊都沒見過,wiki一下發現果然大都是后朋與新浪潮樂隊的歌曲,搞笑的是Love Will Tear Us Apart竟然成了Boy Division的了,這數據集有毒。。
過了半年又沉浸在前衛搖滾的長篇里:
# long progressive
guerbai_playlist = [
('Rush', '2112: Ii. The Temples Of Syrinx'),
('Yes', 'Roundabout'),
('Emerson, Lake & Palmer', 'Take A Pebble'),
('Jethro Tull', 'Aqualung'),
]
display_track_info(recommend_with_playlist([
get_hot_track_id_by_artist_name_and_track_name(track[0], track[1])
for track in guerbai_playlist]))
track_name artist_name
0 Nutrocker Emerson, Lake & Palmer
1 Brain Salad Surgery Emerson, Lake & Palmer
2 Black Moon Emerson, Lake & Palmer
3 Parallels Yes
4 Working All Day Gentle Giant
5 Musicatto Kansas
6 Farewell To Kings Rush
7 My Sunday Feeling Jethro Tull
8 Thick As A Brick, Part 1 Jethro Tull
9 South Side Of The Sky Yes
10 Living In The Past Jethro Tull
11 The Fish (Schindleria Praematurus) Yes
12 Starship Trooper Yes
13 Tank Emerson, Lake & Palmer
14 I Think I'M Going Bald Rush
15 Here Again Rush
16 Lucky Man Emerson, Lake & Palmer
17 Cinderella Man Rush
18 Stick It Out Rush
19 The Speed Of Love Rush
20 New State Of Mind Yes
21 Karn Evil 9: 2Nd Impression Emerson, Lake & Palmer
22 A Venture Yes
23 Cygnus X-1 Rush
24 Sweet Dream Jethro Tull
人是會變的,今天她喜歡聽后朋,明天可能喜歡別的,但既然我們有數學與集體智慧,這又有什么關系呢?
參考
Using Word2vec for Music Recommendations
Word2Vec Tutorial - The Skip-Gram Model