ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 추천시스템 이해
    데이터사이언스/데이터 분석 2024. 3. 17. 18:07

     

     

    기본적인 추천시스템은 어떤 행렬(데이터)을 사용했는지와

    어떻게 분석했는지로 구분할 수 있다.

     

    ex) 영화 추천 시스템

    • 콘텐츠 기반 추천시스템(Content-based Filtering, CB):

    행에는 영화제목이, 열에는 영화 속성이 있는 행렬을 사용한다. (==각 영화를 설명하는 행렬)

    → A와 비슷한 영화는 A’입니다.

     

    비슷한 영화를 계산하는 대표적인 방법으로 코사인 유사도가 있다. 

     

     

     

     

    • 협업 필터링 추천시스템(Collaborative Filtering, CF):

    행에는 영화제목이, 열에는 사용자 개개인이 기록된 행렬을 사용한다. (==각 영화에 대한 평점을 기록한 희소행렬)

    → 아직 관람하지 않은 영화 B의 예측 평점은 5점입니다.

     

    이와 반대로 행에는 사용자, 열에는 영화제목이 있는 행렬도 사용가능하다. 

    전자를 아이템 기반 행렬, 후자를 사용자 기반 행렬이라고 부른다.  서로 회전 관계이다. 

     

     

     

    협업 필터링에 대해서

     

    • 잠재요인 모델

    가장 기본적인 CF모델은 잠재요인 모델 (Latent Factor)이다. 평점 희소행렬 보유하고 있을때, 분석 목적은 결측값을 채워 넣는것.

    이때 희소행렬을 ‘분해하고 다시 곱하여’ 예측행렬을 생성하고, 원래행렬-예측행렬의 차이를 줄이는 최적화를 진행하는 방식이다.

    잠재요인이라고 불리는 이유는 분해된 행렬이 새로운 차원을 갖게되는데 이 차원에 잠재요인이라는 이름을 붙였기 때문이다. (예를들어 영화라면 영화장르가 잠재요인으로 해석될 수 있다.)

    원래행렬-예측행렬의 차이(목적함수)를 줄이는 과정을 신경망으로 할 수도 있고, 베이지안 최적화를 할 수 도 있고, 경사하강법의 일종인 ALS 방법을 쓸 수도 있다. (ALS는 목적함수가 convex가 아닐때 유용한 방법으로 알려져 있다.)

     

     

    • 그 외 모델

    더 발전된 모델들은 단순한 행렬의 곱으로 사용자-아이템 관계를 규명하지 않거나(Neural Collaborative Filtering), 분류/회귀 모델과의 융합(Factorization Machine)을 시도한다.

    또는 Autoencoder를 통한 비지도학습으로 입력한 데이터의 잠재벡터(latent vector)를 생성하여 추천에 사용하기도 한다.

     

     

    • CF 모델의 선택 기준

    - 데이터의 종류.

    평점 행렬은 평점을 기록할 수 도 있지만(명시적 패드백, Explicit Feedback) , 클릭이나 구매 등의 고객의 행동 패턴(암시적 피드백, Implicit Feedback)으로 기록될 수 도 있다.

     

    - 모델평가 기준.

    실질적으로 추천시스템을 구현하면, 그 출력은 하나의 아이템이 될 수도 있고 랭킹 순으로 정렬된 여러개의 문서가 될 수도 있다. 따라서 비즈니스 니즈와 출력에 적합한 평가지표를 사용해야한다. (Metrics)

    예를들어 클릭 확률을 측정하는 Log Loss, 전반적인 예측 성능을 측정하는 AUC, 특정 아이템의 추천 성능을 평가하는 Point-wise Loss, 긍정 아이템과 부정 아이템의 쌍을 고려하는 Pair-wise Loss, 랭킹에 따라 기존에 알고있는 순위를 얼마나 잘 구현하는지 판단하는 nDCG 등이 있다.

     

     

    협업 필터링 구현 

     

    음악 스트리밍 서비스에서 사용자에게 새로운 음악을 추천해주는 상황을 구현해보자. 

     

    사용한 데이터셋 두가지에 대한 정보: 

     

    listen_count.txt는 다음과 같이 <user_id>, <music_id>, <count>가 기록되어있다. 

    18091 6716 3
    15772 1251 1
    ...

     

    test_data.txt 는 다음과 같이 <user_id>, <music_id_1>, <music_id_2>, ... 가 기록되어 있다. 

    15772 3663 2151
    3759 2554 1151 3399
    ...

     

    추천시스템의 성능은 nDCG@100으로 평가한다. 

     

    모듈 import

    import pandas as pd
    import numpy as np
    import math
    from sklearn.metrics import mean_squared_error
    from scipy.sparse import csr_matrix
    from jax import grad, jit, vmap    
    import jax.numpy as jnp
    from implicit.als import AlternatingLeastSquares as ALS

     

    데이터 셋 로드

    tr = pd.read_csv('./parsed/listen_count.txt', sep=' ', header=None, dtype=str)
    tr.columns = ['uid', 'sid', 'cnt']
    tr['cnt'] = tr['cnt'] 
    
    def load_res(fname):
        ret = {}
        with open(fname, 'r') as f:
            for l in f:
                l = l.strip().split()
                uid, sids = l[0], l[1:]
                ret[uid] = sids
        return ret
    gt = load_res('./parsed/TEST_DATA.txt')
    
    # 위의 행렬에서 인덱스-변수간을 연결짓는 딕셔너리 생성
    uid2idx = {_id: i for (i, _id) in enumerate(tr.uid.unique())}                
    sid2idx = {_id: i for (i, _id) in enumerate(tr.sid.unique())}
    idx2uid = {i: _id for (_id, i) in uid2idx.items()}
    idx2sid = {i: _id for (_id, i) in sid2idx.items()}
    
    n_users, n_items = len(uid2idx), len(sid2idx)
    
    tr['uidx'] = tr.uid.apply(lambdaa x: uid2idx[x])
    tr['sidx'] = tr.sid.apply(lambda x: sid2idx[x])
    
    X = csr_matrix((tr.cnt, (tr.uidx, tr.sidx)), shape=(n_users, n_items), dtype=np.float32)
    X.data[:] = 1.0 + np.log(1.0 + X.data[:])
    #최종 사용자-아이템 count행렬  (u x i)
    jX = jnp.array(X.todense())

     

    성능(nDCG) 평가함수 정의

    def calculate_ndcg(top_reco, idx2uid, idx2sid, gt):
        recs = {}
        for idx, rec_list in enumerate(top_reco):
            uid = idx2uid[idx]
            rec_sids = [str(idx2sid[sidx]) for sidx in rec_list]
            recs[uid] = rec_sids
    
        Q, S = 0.0, 0.0
        for u, vs in gt.items():
            rec = recs.get(u, [])
            if not rec:
                continue
    
            idcg = sum([1.0 / math.log(i + 2, 2) for i in range(min(len(vs), len(rec)))])
            dcg = sum([1.0 / math.log(i + 2, 2) for i, r in enumerate(rec) if r in vs])
            ndcg_value = dcg / idcg if idcg > 0 else 0
            S += ndcg_value
            Q += 1
        return S / Q if Q > 0 else 0

     

    행렬분해 기법 1 : 오토 인코더(Shallow)

    # PX로 추정할 것이다. P는 0행렬에 가까운 행렬 (i x i)
    P = jnp.array(np.random.normal(0, 0.001, size=(n_items, n_items)))   
    P = P.at[jnp.diag_indices(P.shape[0])].set(0)
    #P.shape
    
    @jit
    def loss_fn(X, P):
        ret = (((X - X @ P) **2).sum() / X.shape[0])        #행렬의 rmse, 프로베니우스 놈  
        ret = ret + 0.01 * (P * P).sum()
        return ret
    
    @jit
    def loss_fn_with_reg(X, P):
        ret = loss_fn(X, P)                                 #위의 손실함수+릿지 규제, 파라미터를 줄여줌. 
        ret = ret + 0.01 * (P * P).sum()
        return ret 
    
    lr = 0.05
    for i in range(100):
        batch = np.random.choice(X.shape[0], 1024)          #행에서 일부를 랜덤추출 (미니배치)
        l = loss_fn(jX[batch], P)                           #미니배치 열과 P의 행의 차원이 같으므로 손실함수 계산 가능
        #print("loss %.3f" % l)                             #출력한번 해주고
        r = grad(loss_fn_with_reg, argnums=1)(jX[batch], P) #경사하강은 릿지규제가 적용된 손실함수로 진행. 
        P -= lr * r
        P = P.at[jnp.diag_indices(P.shape[0])].set(0)
        lr *= 0.99   
    
    scores = np.asarray(X @ P)
    
    # 유저가 들은 적이 있는 아이템을 추천에서 제외
    scores = np.asarray((scores - X.astype(bool).astype(int) * 10000))  #원래 값이 있던 자리에 음수를 대입함.

     

    top_reco = (-scores).argsort(-1)[:, :100]
    calculate_ndcg(top_reco, idx2uid, idx2sid, gt)
    0.2554862966708626

     

    행렬분해 기법 2 : 행렬 분해(역행렬 근사)

    def gen_top_reco(X, lamb=10.0):
        G = (X.T @ X).toarray()
        diags = np.diag_indices(G.shape[0])
        G[diags] += lamb
        P = np.linalg.inv(G)
        B = P / -np.diag(P)
        scores = X @ B
        scores = np.asarray((scores - X.astype(bool).astype(int) * 10000))
        top_reco = (-scores).argsort(-1)[:, :100]
        return top_reco
    top_reco = gen_top_reco(X, lamb=30.0)
    calculate_ndcg(top_reco, idx2uid, idx2sid, gt)
    0.26335700722241456

     

    행렬분해 기법 3 : 행렬 분해(ALS)

    model = ALS(64, regularization=8.0, alpha=16.0)
    model.fit(X)
    top_reco, scores = model.recommend(np.arange(n_users), X, N=100)
    calculate_ndcg(top_reco, idx2uid, idx2sid, gt)
    0.23238884139750876

    댓글