推薦系統實踐 0x0b 矩陣分解

NoMornings發表於2020-12-04

前言

推薦系統實踐那本書基本上就更新到上一篇了,之後的內容會把各個演算法結合著《深度學習推薦演算法》這本書拿來當專題進行講解。在這一篇,我們將會介紹矩陣分解這一方法。一般來說,協同過濾演算法(基於使用者、基於物品)會有一個比較嚴重的問題,那就是頭部效應。熱門的物品容易跟大量的物品產生相似性,而尾部的物品由於特徵向量係數很少產生與其他物品的相似性,也就很少被推薦。

矩陣分解演算法

為了解決這個問題,矩陣分解演算法在協同過濾演算法中共現矩陣的基礎上加入了隱向量的概念,也是為了增強模型處理稀疏矩陣的能力。物品和使用者的隱向量是通過分解協同過濾的共現矩陣得到的。矩陣分解的主要方法有三種,特徵值分解、奇異值分解以及梯度下降。特徵值分解主要用於方陣,而使用者-物品矩陣並不一定是方陣,所以不太適用。而奇異值分解通過保留對角矩陣較大元素的方式,對矩陣進行分解比較完美的解決了矩陣分解的問題,但是計算複雜度到達了\(O(mn^2)\)的級別,顯然在業務場景當中顯然是無法使用的。所以梯度下降成為了矩陣分解的主要方法。

梯度下降

有過深度學習基礎的同學肯定對梯度下降不陌生,介紹梯度下降的博文也是數不勝數,這裡給出一篇博文作為參考,不再贅述。這裡梯度下降所需要優化的目標函式是

\[\min_{q^*,p^*}\sum_{u,i\in K}(r_{ui}-q_i^Tp_u)^2 \]

其中,\(r_ui\)是使用者\(u\)對物品\(i\)的評分,使用者向量為\(p_u\),物品向量為\(q_i\)

比較有趣的一點是,由於不同使用者的打分體系不一樣,我們還需要消除使用者和物品打分的品茶,通常的做法是加入使用者和物品的偏差向量:

\[r_{ui}=\mu + b_i + b_u + q_i^T p_u \]

其中,\(\mu\)是全域性偏差常數,\(b_i\)是物品\(i\)的偏差係數,可以使用收到所有評分的均值,\(b_u\)是使用者偏差係數,可以使用使用者\(u\)給出所有評分的均值。

優缺點

優點:

  • 泛化能力強,一定程度解決了資料係數的問題。
  • 空間複雜度低。只需要儲存使用者和物品向量,空間複雜度降低到了\((m+n)k\)的級別。
  • 更好地擴充套件性。最終的輸出是使用者和物品的隱向量,所以可以很好地拼接到深度學習當中。
    缺點:
  • 沒有考慮使用者、物品、上下文的特徵。
  • 缺乏使用者歷史行為時無法進行推薦。

實驗

我們在動漫推薦資料集上來實現以下矩陣分解中梯度下降的演算法,資料集已經給出來了。下面給出相對應的程式碼,相關的解釋以及結果我放在註釋當中:

#library imports
import numpy as np
import pandas as pd
from collections import Counter
from sklearn.model_selection import train_test_split
from scipy import sparse

lmbda = 0.0002


def encode_column(column):
    """ Encodes a pandas column with continous IDs"""
    keys = column.unique()
    key_to_id = {key: idx for idx, key in enumerate(keys)}
    return key_to_id, np.array([key_to_id[x] for x in column]), len(keys)


def encode_df(anime_df):
    """Encodes rating data with continuous user and anime ids"""

    anime_ids, anime_df['anime_id'], num_anime = encode_column(
        anime_df['anime_id'])
    user_ids, anime_df['user_id'], num_users = encode_column(
        anime_df['user_id'])
    return anime_df, num_users, num_anime, user_ids, anime_ids


def create_embeddings(n, K):
    """
    Creates a random numpy matrix of shape n, K with uniform values in (0, 11/K)
    n: number of items/users
    K: number of factors in the embedding 
    """
    return 11 * np.random.random((n, K)) / K


def create_sparse_matrix(df, rows, cols, column_name="rating"):
    """ Returns a sparse utility matrix"""
    return sparse.csc_matrix((df[column_name].values,
                              (df['user_id'].values, df['anime_id'].values)),
                             shape=(rows, cols))


def predict(df, emb_user, emb_anime):
    """ This function computes df["prediction"] without doing (U*V^T).
    
    Computes df["prediction"] by using elementwise multiplication of the corresponding embeddings and then 
    sum to get the prediction u_i*v_j. This avoids creating the dense matrix U*V^T.
    """
    df['prediction'] = np.sum(np.multiply(emb_anime[df['anime_id']],
                                          emb_user[df['user_id']]),
                              axis=1)
    return df


def cost(df, emb_user, emb_anime):
    """ Computes mean square error"""
    Y = create_sparse_matrix(df, emb_user.shape[0], emb_anime.shape[0])
    predicted = create_sparse_matrix(predict(df, emb_user,
                                             emb_anime), emb_user.shape[0],
                                     emb_anime.shape[0], 'prediction')
    return np.sum((Y - predicted).power(2)) / df.shape[0]


def gradient(df, emb_user, emb_anime):
    """ Computes the gradient for user and anime embeddings"""
    Y = create_sparse_matrix(df, emb_user.shape[0], emb_anime.shape[0])
    predicted = create_sparse_matrix(predict(df, emb_user,
                                             emb_anime), emb_user.shape[0],
                                     emb_anime.shape[0], 'prediction')
    delta = (Y - predicted)
    grad_user = (-2 / df.shape[0]) * (delta * emb_anime) + 2 * lmbda * emb_user
    grad_anime = (-2 / df.shape[0]) * (delta.T *
                                       emb_user) + 2 * lmbda * emb_anime
    return grad_user, grad_anime


def gradient_descent(df,
                     emb_user,
                     emb_anime,
                     iterations=2000,
                     learning_rate=0.01,
                     df_val=None):
    """ 
    Computes gradient descent with momentum (0.9) for given number of iterations.
    emb_user: the trained user embedding
    emb_anime: the trained anime embedding
    """
    Y = create_sparse_matrix(df, emb_user.shape[0], emb_anime.shape[0])
    beta = 0.9
    grad_user, grad_anime = gradient(df, emb_user, emb_anime)
    v_user = grad_user
    v_anime = grad_anime
    for i in range(iterations):
        grad_user, grad_anime = gradient(df, emb_user, emb_anime)
        v_user = beta * v_user + (1 - beta) * grad_user
        v_anime = beta * v_anime + (1 - beta) * grad_anime
        emb_user = emb_user - learning_rate * v_user
        emb_anime = emb_anime - learning_rate * v_anime
        if (not (i + 1) % 50):
            print("\niteration", i + 1, ":")
            print("train mse:", cost(df, emb_user, emb_anime))
            if df_val is not None:
                print("validation mse:", cost(df_val, emb_user, emb_anime))
    return emb_user, emb_anime


def encode_new_data(valid_df, user_ids, anime_ids):
    """ Encodes valid_df with the same encoding as train_df.
    """
    df_val_chosen = valid_df['anime_id'].isin(
        anime_ids.keys()) & valid_df['user_id'].isin(user_ids.keys())
    valid_df = valid_df[df_val_chosen]
    valid_df['anime_id'] = np.array(
        [anime_ids[x] for x in valid_df['anime_id']])
    valid_df['user_id'] = np.array([user_ids[x] for x in valid_df['user_id']])
    return valid_df


anime_ratings_df = pd.read_csv("../dataset/anime/rating.csv")
print(anime_ratings_df.shape)
# (7813737, 3)

print(anime_ratings_df.head())

#    user_id  anime_id  rating
# 0        1        20      -1
# 1        1        24      -1
# 2        1        79      -1
# 3        1       226      -1
# 4        1       241      -1

anime_ratings = anime_ratings_df.loc[
    anime_ratings_df.rating != -1].reset_index()[[
        'user_id', 'anime_id', 'rating'
    ]]
print(anime_ratings.shape)
# (6337241, 3)

anime_ratings.head()

print(Counter(anime_ratings.rating))
# Counter({8: 1646019, 7: 1375287, 9: 1254096, 10: 955715, 6: 637775, 5: 282806, 4: 104291, 3: 41453, 2: 23150, 1: 16649})


# Average number of ratings per user
print(np.mean(anime_ratings.groupby(['user_id']).count()['anime_id']))
# 91.05231321839081

train_df, valid_df = train_test_split(anime_ratings, test_size=0.2)

# resetting indices to avoid indexing errors in the future
train_df = train_df.reset_index()[['user_id', 'anime_id', 'rating']]
valid_df = valid_df.reset_index()[['user_id', 'anime_id', 'rating']]

anime_df, num_users, num_anime, user_ids, anime_ids = encode_df(train_df)
print("Number of users :", num_users)
print("Number of anime :", num_anime)
# Number of users : 68879
# Number of anime : 9736
anime_df.head()

Y = create_sparse_matrix(anime_df, num_users, num_anime)

# to view matrix
Y.todense()

emb_user = create_embeddings(num_users, 3)
emb_anime = create_embeddings(num_anime, 3)
emb_user, emb_anime = gradient_descent(anime_df,
                                       emb_user,
                                       emb_anime,
                                       iterations=800,
                                       learning_rate=1)
# train mse: 6.10483805880207
print("before encoding:", valid_df.shape)
# before encoding: (1267449, 3)
valid_df = encode_new_data(valid_df, user_ids, anime_ids)
print("after encoding:", valid_df.shape)
# after encoding: (1266340, 3)

train_mse = cost(train_df, emb_user, emb_anime)
val_mse = cost(valid_df, emb_user, emb_anime)
print(train_mse, val_mse)
# 6.10483805880207 6.128041043787572

參考

動漫推薦資料集
矩陣分解實現

相關文章