카카오 아레나 멜론 플레이리스트 추천 시스템 구현 및 개발 코드

유상준·2022년 9월 19일
0

Colab drive mount


#cd /content/drive/MyDrive/네카라쿠배/최종 프로젝트/code

#ls

#!pip install nbformat

# Module import, Pre-Work

from collections import Counter

import numpy as np
import pandas as pd

import scipy.sparse as spr
import pickle

# 결과값 저장하기 위한 write_json 함수 작성
# arena_util.py
# -*- coding: utf-8 -*-

import io
import os
import json
import distutils.dir_util
from collections import Counter

import numpy as np


def write_json(data, fname):
    def _conv(o):
        if isinstance(o, np.int64) or isinstance(o, np.int32):
            return int(o)
        raise TypeError

    parent = os.path.dirname(fname)
    distutils.dir_util.mkpath("../data/" + parent)
    with io.open("../data/" + fname, "w", encoding="utf8") as f:
        json_str = json.dumps(data, ensure_ascii=False, default=_conv)
        f.write(json_str)


def load_json(fname):
    with open(fname, encoding='utf8') as f:
        json_obj = json.load(f)

    return json_obj


def debug_json(r):
    print(json.dumps(r, ensure_ascii=False, indent=4))


# Load Data

ls

song_meta = pd.read_json("../data/song_meta.json")
train = pd.read_json("../data/train.json")
test = pd.read_json("../data/val.json")

Data Preprocessing

playlist, song, tag의 id(각각 nid, sid, tid)를 새로 생성하는 이유는, 새로 생성할 id를 matrix의 row, column index로 사용할 것이기 때문입니다.

  • plylst_id_nid : playlist id -> nid
  • plylst_nid_id : playlist nid -> id
  • song_id_sid : song id -> sid
  • song_sid_id : song sid -> id
  • tag_id_tid : tag id -> tid
  • tag_tid_id : tag tid -> id
  • song_dict : song id -> count
  • tag_dict : tag id -> count
# train + test => new train
plylst = pd.concat([train, test], ignore_index=True)
n_plylst = len(plylst)

# playlist id
plylst["nid"] = range(n_plylst)

# id <-> nid
plylst_id_nid = dict(zip(plylst["id"],plylst["nid"]))
plylst_nid_id = dict(zip(plylst["nid"],plylst["id"]))

plylst_tag = plylst['tags']
tag_counter = Counter([tg for tgs in plylst_tag for tg in tgs])
tag_dict = {x: tag_counter[x] for x in tag_counter}

tag_id_tid = dict()
tag_tid_id = dict()
for i, t in enumerate(tag_dict):
  tag_id_tid[t] = i
  tag_tid_id[i] = t

n_tags = len(tag_dict)

plylst_song = plylst['songs']
song_counter = Counter([sg for sgs in plylst_song for sg in sgs])
song_dict = {x: song_counter[x] for x in song_counter}

song_id_sid = dict()
song_sid_id = dict()
for i, t in enumerate(song_dict):
  song_id_sid[t] = i
  song_sid_id[i] = t

n_songs = len(song_dict)

# plylst의 songs와 tags를 새로운 id로 변환하여 DataFrame에 추가합니다

plylst['songs_id'] = plylst['songs'].map(lambda x: [song_id_sid.get(s) for s in x if song_id_sid.get(s) != None])
plylst['tags_id'] = plylst['tags'].map(lambda x: [tag_id_tid.get(t) for t in x if tag_id_tid.get(t) != None])

plylst_use = plylst[['nid','updt_date','songs_id','tags_id']]
plylst_use.loc[:,'num_songs'] = plylst_use['songs_id'].map(len)
plylst_use.loc[:,'num_tags'] = plylst_use['tags_id'].map(len)
plylst_use = plylst_use.set_index('nid')

plylst_train = plylst_use.copy()
plylst_train.head()

Make Sparse Matrix with scipy.sparse

  • row가 playlist(nid)이고 column이 item(sid or tid)인 sparse matrix A를 만듭니다.
row = np.repeat(range(n_plylst), plylst_train['num_songs'])
col = [song for songs in plylst_train['songs_id'] for song in songs]
dat = np.repeat(1, plylst_train['num_songs'].sum())
train_songs_A = spr.csr_matrix((dat, (row, col)), shape=(n_plylst, n_songs))

row = np.repeat(range(n_plylst), plylst_train['num_tags'])
col = [tag for tags in plylst_train['tags_id'] for tag in tags]
dat = np.repeat(1, plylst_train['num_tags'].sum())
train_tags_A = spr.csr_matrix((dat, (row, col)), shape=(n_plylst, n_tags))

#전치행렬이 필요없는것으로 판단되어 코드 제거
#train_songs_A_T = train_songs_A.T.tocsr()
#train_tags_A_T = train_tags_A.T.tocsr()

train_songs_A

1 - (5707070/(138086*638336))

# Sparsity: 얼마나 비어있나?
# Sparsity of plylst x songs matrix
matrix_size = train_songs_A.shape[0]* train_songs_A.shape[1]
num_songs = len(train_songs_A.nonzero()[0])
sparsity = 100 * (1 - (num_songs / matrix_size))
sparsity

train_tags_A

1 - (503669/(138086*30197))

# Sparsity: 얼마나 비어있나?
# Sparsity of plylst x tags matrix
matrix_size = train_tags_A.shape[0]* train_tags_A.shape[1]
num_tags = len(train_tags_A.nonzero()[0])
sparsity = 100 * (1 - (num_tags / matrix_size))
sparsity

# 첫번째 한계점 발생 : sparsity가 대략 99.5% 이하 수준일 때까지 Collaborative Filtering이 효과가 있는데, 우리의 데이터는 99.9%를 웃돈다.. 

Make train, test sparse matrix

의사 결정의 갈림길

  • train set에서 일부를 가려 test set을 생성할 것이다.

  • 기존의 val.json의 songs과 tags는 일부가 가려져있다.

  • 그렇다면 이 상황에서,

      1. train.json의 일정 비율 만큼 songs들과 tags들만 가릴것이냐?
      1. train+val 의 데이터에서 songs들과 tags들을 일정 비율 함께 가릴것이냐?
  • 우선 2번의 방법을 선택 후 진행 => 추후 변경 가능의 여지를 남겨두자
import random
def make_train (matrix, percentage = .2):
    '''
    -----------------------------------------------------
    설명
    유저-아이템 행렬 (matrix)에서 
    1. 0 이상의 값을 가지면 1의 값을 갖도록 binary하게 테스트 데이터를 만들고
    2. 훈련 데이터는 원본 행렬에서 percentage 비율만큼 0으로 바뀜
    
    -----------------------------------------------------
    반환
    training_set: 훈련 데이터에서 percentage 비율만큼 0으로 바뀐 행렬
    test_set:     원본 유저-아이템 행렬의 복사본
    user_inds:    훈련 데이터에서 0으로 바뀐 유저의 index
    '''
    test_set = matrix.copy()
    test_set[test_set !=0] = 1 # binary하게 만들기
    
    training_set = matrix.copy()
    nonzero_inds = training_set.nonzero()
    nonzero_pairs = list(zip(nonzero_inds[0], nonzero_inds[1]))
    
    random.seed(0)
    num_samples = int(np.ceil(percentage * len(nonzero_pairs)))
    samples = random.sample(nonzero_pairs, num_samples)
    
    user_inds = [index[0] for index in samples]
    item_inds = [index[1] for index in samples]
    
    training_set[user_inds, item_inds] = 0
    training_set.eliminate_zeros()
    
    return training_set, test_set, list(set(user_inds))

# 훈련, 테스트 데이터 생성
train_songs_A, test_songs_A, product_users_altered = make_train(train_songs_A, 0.2)
train_tags_A, test_tags_A, product_users_altered = make_train(train_tags_A, 0.2)

# 훈련데이터는 일부분의 1이 0으로 가려지고, 테스트 데이터는 가려지지 않은 데이터
# 훈련데이터로 학습하고 테스트 데이터로 성능을 평가하기 위한 방법

- train_songs_A 와 test_songs_A의 비교

train_songs_A

test_songs_A

# train_songs_A 의 Sparsity
1 - (4565656/(138086*638336))

ALS 구현 (without implicit module)

# from scipy.sparse.linalg import spsolve

'''
def implicit_weighted_ALS(training_set, lambda_val =.1, alpha = 40, n_iter = 10, rank_size = 20, seed = 0):
'''

#     협업 필터링에 기반한 ALS
#     -----------------------------------------------------
#     input
#     1. training_set : m x n 행렬로, m은 유저 수, n은 아이템 수를 의미. csr 행렬 (희소 행렬) 형태여야 함 
#     2. lambda_val: ALS의 정규화 term. 이 값을 늘리면 bias는 늘지만 분산은 감소. default값은 0.1
#     3. alpha: 신뢰 행렬과 관련한 모수 (C_{ui} = 1 + alpha * r_{ui}). 이를 감소시키면 평점 간의 신뢰도의 다양성이 감소
#     4. n_iter: 반복 횟수
#     5. rank_size: 유저/ 아이템 특성 벡터의 잠재 특성의 개수. 논문에서는 20 ~ 200 사이를 추천하고 있음. 이를 늘리면 과적합 위험성이 있으나 
#     bias가 감소
#     6. seed: 난수 생성에 필요한 seed
#     -----------------------------------------------------
#     반환
#     유저와 아이템에 대한 특성 벡터
    '''
    
    # 1. Confidence matrix
    # C = 1+ alpha * r_{ui}
    conf = (alpha*training_set) # sparse 행렬 형태를 유지하기 위해서 1을 나중에 더함
    
    num_user = conf.shape[0]
    num_item = conf.shape[1]

    # X와 Y 초기화
    rstate = np.random.RandomState(seed)
    X = spr.csr_matrix(rstate.normal(size = (num_user, rank_size)))
    Y = spr.csr_matrix(rstate.normal(size = (num_item, rank_size)))
    X_eye = spr.eye(num_user)
    Y_eye = spr.eye(num_item)
    
    # 정규화 term: 𝝀I
    lambda_eye = lambda_val * spr.eye (rank_size)
    
    # 반복 시작
    for i in range(n_iter):
        yTy = Y.T.dot(Y)
        xTx = X.T.dot(X)
        
        # Y를 고정해놓고 X에 대해 반복
        # Xu = (yTy + yT(Cu-I)Y + 𝝀I)^{-1} yTCuPu
        for u in range(num_user):
            conf_samp = conf[u,:].toarray() # Cu
            pref = conf_samp.copy()
            pref[pref!=0] = 1
            # Cu-I: 위에서 conf에 1을 더하지 않았으니까 I를 빼지 않음 
            CuI = spr.diags(conf_samp, [0])
            # yT(Cu-I)Y
            yTCuIY = Y.T.dot(CuI).dot(Y)
            # yTCuPu
            yTCupu = Y.T.dot(CuI+Y_eye).dot(pref.T)
            
            X[u] = spsolve(yTy + yTCuIY + lambda_eye, yTCupu)
        
        # X를 고정해놓고 Y에 대해 반복
        # Yi = (xTx + xT(Cu-I)X + 𝝀I)^{-1} xTCiPi
        for i in range(num_item):
            conf_samp = conf[:,i].T.toarray()
            pref = conf_samp.copy()
            pref[pref!=0] = 1
            
            #Ci-I
            CiI = spr.diags (conf_samp, [0])
            # xT(Ci-I)X
            xTCiIX = X.T.dot(CiI).dot(X)
            # xTCiPi
            xTCiPi = X.T.dot(CiI+ X_eye).dot(pref.T)
            
            Y[i] = spsolve(xTx + xTCiIX + lambda_eye, xTCiPi)
            
        return X, Y.T
'''


# 1회 반복 수행해보기
# lambda = 0.1, alpha = 30, latent dimension = 100

user_vecs, item_vecs = implicit_weighted_ALS(train_songs_A, lambda_val = 0.1, alpha = 30, n_iter = 1, rank_size = 100)

# 계산량이 상당히 많아서 그런지, 1회 반복임에도 오래걸렸다. (~분)
# 이를 해결하기 위해 implicit module을 이용한다고 한다.
# 병렬처리가 
# 첫번째 플레이리스트의 벡터 확인해보기
first = user_vecs[0].dot(item_vecs).toarray() # 1x
first[0,:5]

ALS구현 (with implicit module)

#pip install --upgrade pip setuptools wheel

#!pip install implicit

- alpha 값이 parameter로 지정 할 수 없기 때문에, 따로 지정해줘서 sparse matrix와 곱한 결과 인자로 넣어줘야함 (train_songs_A * alpha)
- sparse matrix의 자료형이 double이어야함 (상관 없는 것 같다)

#Building the model - plylst x song
model = implicit.als.AlternatingLeastSquares(factors=200, regularization=0.1, iterations=1, random_state=13)
alpha_val = 30
data_conf = (train_songs_A * alpha_val) # double이나 int나 거의 비슷한 결과
model.fit(data_conf.T)

#Building the model - plylst x song
model2 = implicit.als.AlternatingLeastSquares(factors=30, regularization=0.1, iterations=1, random_state=13)
alpha_val = 30
data_conf = (train_tags_A * alpha_val) # double이나 int나 거의 비슷한 결과
model2.fit(data_conf.T)

### 모델 만드는 함수 build_model, 모델 적합 함수 fit_model 만들기

import implicit

def build_model(factors=200, regularization=0.1, iteration=100, random_state=13):
    model = implicit.als.AlternatingLeastSquares(factors=factors, regularization=regularization,
                                                 iterations=iteration, random_state=random_state)
    
    return model

def fit_model(model, ui_sparse_matrix, alpha = 40):
    #global model
    data_conf = (ui_sparse_matrix * alpha)
    model.fit(data_conf.T)
  • 하이퍼 파라미터 종류
alpha : 신뢰 행렬 만들 때의 파라미터, 플레이리스트에 노래의 존재 여부에 대한 스케일링 term이고 평점간의 신뢰도의 다양성과 정비례
factors : 잠재행렬의 차원의 수 (줄이고 싶은 차원의 수)
regularization : The regularization factor to use
iterations : 반복 수
random_state : 초기 값 설정시 random seed
  • song_model : playlist와 song에 대한 학습
  • tag_model : playlist와 tag에 대한 학습

곡 추천 함수 만들기

def recommend(nid):

  • 찾고자 하는 플레이리스트의 nid를 받는다
  • 미리 학습한 모델을 기반으로 300개와 30개의 tag를 추천해준다
  • 그 중 존재 하지 않는 곡들과 tag들에 한해 추천 목록에 추가해준다
def recommend(nid, song_model, tag_model):
    rec_sid = song_model.recommend(nid,test_songs_A, N = 300)
    rec_tid = tag_model.recommend(nid,test_tags_A, N = 30)
    
    exist_sid = plylst_train.loc[nid]['songs_id']
    exist_tid = plylst_train.loc[nid]['tags_id']
    
    final_rec_songs = []
    final_rec_tags = []
    
    for sid, score in rec_sid:
        if sid not in exist_sid:
            final_rec_songs.append(song_sid_id[sid])
            if len(final_rec_songs) == 100:
                break
    for tid, score in rec_tid:
        if tid not in exist_tid:
            final_rec_tags.append(tag_tid_id[tid])
            if len(final_rec_tags) == 10:
                break
    
    return plylst_nid_id[nid], final_rec_songs, final_rec_tags
    

def recommend_songs(nid, song_model):
    rec_sid = song_model.recommend(nid,test_songs_A, N = 300)
    
    exist_sid = plylst_train.loc[nid]['songs_id']
    
    final_rec_songs = []
    
    for sid, score in rec_sid:
        if sid not in exist_sid:
            final_rec_songs.append(song_sid_id[sid])
            if len(final_rec_songs) == 100:
                break
    
    return plylst_nid_id[nid], final_rec_songs
    

def recommend_tags(nid, tag_model):
    rec_tid = tag_model.recommend(nid,test_tags_A, N = 30)
    
    exist_tid = plylst_train.loc[nid]['tags_id']
    
    final_rec_tags = []
    
    for tid, score in rec_tid:
        if tid not in exist_tid:
            final_rec_tags.append(tag_tid_id[tid])
            if len(final_rec_tags) == 10:
                break
    return plylst_nid_id[nid], final_rec_tags
    

recommend_ver2 : tag 예측 성능이 좋지 못하여서 개선한 함수

    1. 적합이 완료된 song_model을 이용하여 similar한 playlists 30개를 받아온다.
    1. similar한 순서대로 playlists들의 tag를 가져와서 추가한다
    1. tag가 10개가 차면 멈춘다.
def recommend_ver2(nid, song_model):
    rec_sid = song_model.recommend(nid,test_songs_A, N = 300)
    sim_user_idxs = song_model.similar_users(nid, N = 500)
    
    exist_sid = plylst_train.loc[nid]['songs_id']
    exist_tid = plylst_train.loc[nid]['tags_id']
    
    final_rec_songs = []
    final_rec_tags_tid = []
    
    # 노래 추천
    for sid, score in rec_sid:
        if sid not in exist_sid:
            final_rec_songs.append(song_sid_id[sid])
            if len(final_rec_songs) == 100:
                break
                
    # 태그 추천
    for sim_nid, score in sim_user_idxs[1:]: # tid (중복제외) 10개를 추천 받아보자
        rec_tids = plylst_train.loc[sim_nid,'tags_id']
        for each in rec_tids:
            if (each not in exist_tid): # 기존 플레이리스트에 없다면,
                final_rec_tags_tid.extend(rec_tids) # 추천 플레이리스트(tid로 이루어진)에 넣어라
                final_rec_tags_tid = set(final_rec_tags_tid) # 중복 제거
                final_rec_tags_tid = list(final_rec_tags_tid) # 중복 제거

        if len(final_rec_tags_tid) > 10:
            final_rec_tags_tid = final_rec_tags_tid[:10]
            break
    
    final_rec_tags = [] # tid를 실제 tag이름으로 바꿔주자
    for each in final_rec_tags_tid:
        final_rec_tags.append(tag_tid_id[each])
                        
    return plylst_nid_id[nid], final_rec_songs, final_rec_tags

def recommend_tags_ver2(nid, song_model):
    sim_user_idxs = song_model.similar_users(nid, N = 500)
    
    exist_tid = plylst_train.loc[nid]['tags_id']
    
    final_rec_tags_tid = []
                
    for sim_nid, score in sim_user_idxs[1:]: # tid (중복제외) 10개를 추천 받아보자
        rec_tids = plylst_train.loc[sim_nid,'tags_id']
        final_rec_tags_tid.extend(rec_tids)
        final_rec_tags_tid = set(final_rec_tags_tid) # 중복 제거
        final_rec_tags_tid = list(final_rec_tags_tid) # 중복 제거
        if len(final_rec_tags_tid) > 10:
            final_rec_tags_tid = final_rec_tags_tid[:10]
            break
    
    final_rec_tags = [] # tid를 실제 tag이름으로 바꿔주자
    for each in final_rec_tags_tid:
        final_rec_tags.append(tag_tid_id[each])
                
    return plylst_nid_id[nid], final_rec_tags

model 생성 및 fit

song_model = build_model(factors = 2500, iteration = 5)
tag_model = build_model(factors = 30, iteration = 30)

song_model, tag_model

fit_model(song_model, test_songs_A, alpha = 100)
fit_model(tag_model, test_tags_A, alpha = 80)

song_model.user_factors.shape, song_model.item_factors.shape

np.save('../data/user_factors_1000.npy', song_model.user_factors)
np.save('../data/item_factors_1000.npy', song_model.item_factors)

sum(song_model.user_factors[0] * song_model.item_factors[67])

### 플레이리스트에 곡과 태그 추천해주기

from tqdm import tqdm
answer = []
for idx in tqdm(range(115071,138086)): # test nid start : 115071
    result = recommend(idx, song_model, tag_model)
    answer.append({
        'id' : result[0],
        'songs' : result[1],
        'tags' : result[2]
    })

# tag 하이퍼 파라미터만 변경해서 따로 작업했을 때

for idx in tqdm(range(115071,138086)):
    tag_result = recommend_tags_ver2(idx, song_model)
    answer[idx-115071]['tags'] = tag_result[1]

# simillar user 이용해서 tag 추천하는 recommend_ver2로 전체 결과 추천할 때

from tqdm import tqdm
answer = []
for idx in tqdm(range(115071,138086)): # test nid start : 115071
    result = recommend_ver2(idx, song_model)
    answer.append({
        'id' : result[0],
        'songs' : result[1],
        'tags' : result[2]
    })

# 노래 추천 잘 됐는지 확인
len_wrong_cnt = 0
len_unique_wrong_cnt = 0
wrong_idx = []
for i in range(len(answer)):
    if len(answer[i]['songs']) != 100:
        len_wrong_cnt += 1
        wrong_idx.append({i : len(answer[i]['songs'])})
        
len(answer), len_wrong_cnt, wrong_idx

# 태그 추천 잘 됐는지 확인
len_wrong_cnt = 0
len_unique_wrong_cnt = 0
wrong_idx = []
for i in range(len(answer)):
    if len(answer[i]['tags']) != 10:
        len_wrong_cnt += 1
        wrong_idx.append({i : len(answer[i]['tags'])})
        
len(answer), len_wrong_cnt, wrong_idx

plylst.iloc[115071,:]

answer[0]

결과물 생성

  • write_json : json파일 저장 함수
import io
import os
import json
import distutils.dir_util
from collections import Counter

import numpy as np


def write_json(data, fname):
    def _conv(o):
        if isinstance(o, np.int64) or isinstance(o, np.int32):
            return int(o)
        raise TypeError

    parent = os.path.dirname(fname)
    distutils.dir_util.mkpath("../data/" + parent)
    with io.open("../data/" + fname, "w", encoding="utf8") as f:
        json_str = json.dumps(data, ensure_ascii=False, default=_conv)
        f.write(json_str)

write_json(answer, "similar_user/f_2500/results.json")

가장 결과가 좋았던 tag추천과 가장 결과가 좋았던 song추천 합치기

ls

base_answer = load_json('/Users/jun/Documents/study/ds_study/code/results.json')

base_answer

base_answer[0]['tags']

for i in range(len(answer)):
    answer[i]['tags'] = base_answer[i]['tags']

answer[0]['tags']

write_json(answer, "similar_user/best_result/results.json")

(번외)함수 만드는 과정에서 썼던 테스트 코드들

  • 2번 nid를 가진 playlist의 맞춤 태그를 추천해보자
rec_tid = model2.recommend(2,train_tags_A, N = 30)
rec_tid[:10]

for tid,_ in rec_tid:
    print(tag_tid_id[tid])

- 2(nid)플레이리스트에 추천해줄만한 곡을 상위 10개만 찾아보자

rec_sid = model.recommend(2,train_songs_A, N = 300)
rec_sid[:10]

for sid, score in rec_sid[:10]:
    print(song_sid_id[sid])
    print(song_meta.loc[song_sid_id[sid]]['song_name'])
    print(song_meta.loc[song_sid_id[sid]]['artist_name_basket'])
    print(song_meta.loc[song_sid_id[each]]['song_gn_gnr_basket'])
    print('------------------------')

- 그렇다면 현재 들어있는 2(nid) 플레이리스트의 노래들을 찾아보자

plylst_nid_id[2] #원래 plylst id = 76951

exist_sid = plylst_train.loc[2]['songs_id'] #담겨있는 songs_sid
len(exist_sid), exist_sid[:5]

for each in exist_sid:
    print(song_sid_id[each])
    print(song_meta.loc[song_sid_id[each]]['song_name'])
    print(song_meta.loc[song_sid_id[each]]['artist_name_basket'])
    print(song_meta.loc[song_sid_id[each]]['song_gn_gnr_basket'])
    print('------------------------')

song_meta.loc[song_sid_id[sid]]

# 115071 부터 test set에 대한 idx
plylst_nid_id[115071]

song_model.similar_users(115071,300)[-5:]

#plylst.loc[115071,:]
plylst_train.loc[72420,:]
profile
데이터 사이언티스트 지망생

2개의 댓글

comment-user-thumbnail
2022년 12월 13일

안녕하세요 :) 혹시 어떤 환경에서 실행하셨는지 알 수 있을까요? 코랩 무료버전에서는 돌아가지 않네요 ㅠ

답글 달기
comment-user-thumbnail
2022년 12월 13일

또한 model 생성 및 fit 세션에서 recommend와 recommend_ver2 함수 실행에 있어서 user_items must contain 1 row for every user in userids 오류가 뜨던데 문제없이 작동하는 것인지 여쭤보고 싶습니다!

답글 달기