前言
在前兩章,我們已對本系列的數據集、評價指標做了相應的介紹,從本章開始將進行推薦實戰,算法上從最經典的矩陣分解講起。
如果你對本系列(未寫完,持續更新中)感興趣,可接以下傳送門:
【推薦算法】從零開始做推薦(一)——認識推薦、認識數據
【推薦算法】從零開始做推薦(二)——推薦系統的評價指標,計算原理與實現樣例
【推薦算法】從零開始做推薦(三)——傳統矩陣分解的TopK推薦
【推薦算法】從零開始做推薦(四)——python Keras框架 利用Embedding實現矩陣分解TopK推薦
【推薦算法】從零開始做推薦(五)——貝葉斯個性化排序矩陣分解 (BPRMF) 推薦實戰
【推薦算法】從零開始做推薦(六)——貝葉斯性化排序矩陣分解 (BPRMF) 的Tensorflow版
矩陣分解
本系列以實戰爲主,算法上講下大概的思想。矩陣分解較全的分析介紹可參照機器學習矩陣分解解析Recommender.Matrix.Factorization。本文實現的是最簡的矩陣分解版本,詳細思想參照矩陣分解在協同過濾推薦算法中的應用,主體算法公式參照梯度下降的矩陣分解公式推導。
什麼是矩陣分解?
下面進行簡單介紹。矩陣分解,顧名思義是將矩陣進行拆分,線性代數裏兩個矩陣相乘可以得到一個新的矩陣,而矩陣分解的思想就是矩陣乘法的逆運用,將一個矩陣分解成兩個矩陣相乘。注意,這裏的分解是近似的,因爲有可能找不出兩個矩陣的乘積恰好等於原矩陣。而這個近似就給推薦帶來了思路。
分解前的矩陣是什麼?
答:用戶與項目的交互矩陣,具體含義可以是評分、簽到、點擊等等。大致分爲兩類,一類是0-1矩陣,即有過交互即爲1,否則爲0;另一類是具體的數值,根據交互的次數、評價等得出。這裏我們用Rating代指分解前的矩陣。
分解後的兩個矩陣分別是什麼?
答:分別爲用戶隱因子矩陣、項目隱因子矩陣,中間維度K認爲是特徵的數量。這樣一來就用兩個矩陣分別表示了用戶與項目。假設Rating的維度是M×N的,那麼就可以分解爲M×K的User矩陣,K×N的Item矩陣,滿足矩陣Rating和矩陣[User×Item]中的非零項儘可能相等。
爲什麼要矩陣分解?
答:無論Rating是何種矩陣,我們都默認了如果用戶和項目未交互則對應位置填0,但實際上用戶對該項目是可能感興趣的,如何得到這個興趣程度呢?我們讓分解後的矩陣儘量與原矩陣逼近,注意,逼近的只有原矩陣的非零項。因爲是近似的分解,所以得到的新矩陣,原先非零位置就有了數值,於是我們就可以根據新矩陣的數值由大到小進行排序取前K個進行TopK推薦。
核心算法
自己寫是不可能自己寫的,Github上雖然有很多,但都是面向對象的寫法。面向對象的代碼儘管複用性強,也易於修改,但就是寫得太羅嗦,不能很直觀的理解。
這裏與理論統一,對網上一個廣泛流傳的版本進行修改,先看理論部分:
修改完的代碼如下,採用的是最簡單版本的梯度下降,步長alpha和正則lamda(正確打法是lambda,代碼寫lamda是爲了與python中的關鍵字作區分)固定。
對梯度下降感興趣的可以重點看下這段代碼,關鍵之處在於梯度下降結束的三個條件:
梯度下降結束條件:
1.滿足最大迭代次數;
2.loss過小;
3.loss之差過小,梯度消失。
# -*- coding: utf-8 -*-
"""
Created on Fri Apr 10 14:42:17 2020
@author: Yang Lechuan
"""
import numpy as np
import time
def matrix_factorization(R,P,Q,d,steps,alpha=0.05,lamda=0.002):
Q=Q.T
sum_st = 0 #總時長
e_old = 0 #上一次的loss
flag = 1
for step in range(steps): #梯度下降結束條件:1.滿足最大迭代次數,跳出
st = time.time()
e_new = 0
for u in range(len(R)):
for i in range(len(R[u])):
if R[u][i]>0:
eui=R[u][i]-np.dot(P[u,:],Q[:,i])
for k in range(d):
P[u][k] = P[u][k] + alpha*eui * Q[k][i]- lamda *P[u][k]
Q[k][i] = Q[k][i] + alpha*eui * P[u][k]- lamda *Q[k][i]
cnt = 0
for u in range(len(R)):
for i in range(len(R[u])):
if R[u][i]>0:
cnt = cnt + 1
e_new = e_new + pow(R[u][i]-np.dot(P[u,:],Q[:,i]),2)
et = time.time()
e_new = e_new / cnt
if step == 0: #第一次不算loss之差
e_old = e_new
continue
sum_st = sum_st + (et-st)
if e_new<1e-3:#梯度下降結束條件:2.loss過小,跳出
flag = 2
break
if e_old - e_new<1e-10:#梯度下降結束條件:3.loss之差過小,梯度消失,跳出
flag = 3
break
else:
e_old = e_new
print('---------Summary----------\n',
'Type of jump out:',flag,'\n',
'Total steps:',step + 1,'\n',
'Total time:',sum_st,'\n',
'Average time:',sum_st/(step+1.0),'\n',
"The e is:",e_new)
return P,Q.T
R=[
[5,2,0,3,1],
[0,2,1,4,5],
[1,1,0,2,4],
[2,2,0,5,0]
]
d = 3
steps = 5000
N = len(R)
M = len(R[0])
P = np.random.normal(loc=0,scale=0.01,size=(N,d)) #正態分佈隨機初始化
Q = np.random.normal(loc=0,scale=0.01,size=(M,d))
nP,nQ = matrix_factorization(R,P,Q,d,steps)
print('-----原矩陣R:------')
print(R)
print('-----近似矩陣nR:------')
print(np.dot(nP,nQ.T))
這塊代碼的目的是爲了驗證,我們的矩陣分解算法是否真的做到了分解。來看看結果。
簡單來講,行就是用戶,列就是項目,數值可看成評分。可以看出,原先爲0的位置也有了評分,我們依靠新矩陣的評分進行推薦。
注意,這裏的e是指損失函數,用的是MSE,計算公式如下:
ML100K實現完整的矩陣分解TopK推薦
數據集介紹及訓練集測試集劃分請看【推薦算法】從零開始做推薦(一)——認識推薦、認識數據
評價指標請看【推薦算法】從零開始做推薦(二)——推薦系統的評價指標,計算原理與實現樣例。
簡單的數據集介紹:
構造矩陣
要將矩陣分解的核心算法進行應用到數據集上,首先要根據數據集得到矩陣R,數據集直接由評分這列,非常簡單就可以實現。
def getUI(dsname,dformat): #獲取全部用戶和項目
st = time.time()
train = pd.read_csv(dsname+'_train.txt',header = None,names = dformat)
test = pd.read_csv(dsname+'_test.txt',header = None,names = dformat)
data = pd.concat([train,test])
all_user = np.unique(data['user'])
all_item = np.unique(data['item'])
train.sort_values(by=['user','item'],axis=0,inplace=True) #先按時間、再按用戶排序
num_user = max(all_user)+1
num_item = max(all_item)+1
rating = np.zeros([num_user,num_item])
for i in range(0,len(train)):
user = train.iloc[i]['user']
item = train.iloc[i]['item']
score = train.iloc[i]['rating']
# score = 1
rating[user][item] = score
if os.path.exists('./Basic MF'):
pass
else:
os.mkdir('./Basic MF')
train.to_csv('./Basic MF/train.txt',index = False,header=0)
test.to_csv('./Basic MF/test.txt',index = False,header=0)
np.savetxt('./Basic MF/rating.txt',rating,delimiter=',',newline='\n')
et = time.time()
print("get UI complete! cost time:",et-st)
爲了在多次實驗裏節省時間,我們將構建好的數據存在磁盤中,省得每次都要運行佔內存。於是我們還需要一個從本地讀數據的函數。
def getData(dformat):
rating = np.loadtxt('./Basic MF/rating.txt',delimiter=',',dtype=float)
train = pd.read_csv('./Basic MF/train.txt',header = None,names = dformat)
test = pd.read_csv('./Basic MF/test.txt',header = None,names = dformat)
data = pd.concat([train,test])
all_user = np.unique(data['user'])
all_item = np.unique(data['item'])
return rating,train,test,all_user,all_item
訓練
接下來就可以訓練了,訓練時包裝成函數,方便調用。同樣,爲了多次實驗方便,我們將訓練完成的數據存在本地,在測試的時候調用。
def train(rating,d,steps):
R = rating
N=len(R) #用戶數
M=len(R[0]) #項目數
P = np.random.normal(loc=0,scale=0.001,size=(N,d))
Q = np.random.normal(loc=0,scale=0.001,size=(M,d))
nP,nQ = matrix_factorization(R,P,Q,d,steps)
# nR=np.dot(nP,nQ.T)
np.savetxt('./Basic MF/nP.txt',nP,delimiter=',',newline='\n')
np.savetxt('./Basic MF/nQ.txt',nQ,delimiter=',',newline='\n')
測試
在測試之前還需要幾個步驟,首先是評價指標,這裏選用本系列(二)中的PRE,REC,MAP,MRR。代碼就不單獨展示了,後文有全部代碼。
除此之外,我們還需要一個TopK排序的算法。這也是TopK推薦的特色,我們需要返回評分最高的前K個項目,根據評分而得出項目,這裏存在評分和項目的對應關係,因此用字典來實現。實現方法就每次找最高的,直到找滿K個爲止。
def topk(dic,k):
keys = []
values = []
for i in range(0,k):
key,value = max(dic.items(),key=lambda x: x[1])
keys.append(key)
values.append(value)
dic.pop(key)
return keys,values
接下來就是對測試的思考,首先明確,測試的對象是誰?是測試集裏的用戶! 而非全部用戶。
其次,這裏提出幾點限制:
限制1. 必須推薦給用戶其未訪問過的項目。 從直覺上來講,用戶訪問過的項目用戶本身自己知道,如果用戶已知一個項目還去交互的話,他帶着非常強的主觀意願,此時你推薦或不推薦都不影響他的決策。因此,推薦用戶已知的項目,意義是不大的。推薦系統應該更關注用戶的深層偏好,通俗來講就是AI找到你自己都不知道的愛好,給人一種驚喜感。
限制2. 單次推薦中不能給用戶重複推薦項目。 這很好理解,實際上這一步是怕用戶真實訪問記錄裏有相同的,而TopK推薦只可能推薦一個項目一次,因此若不去重,則評價指標會偏低。
這個限制轉化成代碼分爲三步:
1)去掉測試集中某目標用戶已訪問過的項目。
2)將測試集中某目標用戶的真實訪問記錄去重。
3)若經過1)2)後測試集中某目標用戶的真實訪問記錄變爲空集,則跳過且不計數。
經過上述討論,我們得到如下代碼:
def test(train_data,test_data,all_item,dsname,k):
nP = np.loadtxt('./Basic MF/nP.txt',delimiter=',',dtype=float)
nQ = np.loadtxt('./Basic MF/nQ.txt',delimiter=',',dtype=float)
PRE = 0
REC = 0
MAP = 0
MRR = 0
AP = 0
HITS = 0
sum_R = 0
sum_T = 0
valid_cnt = 0
stime = time.time()
test_user = np.unique(test_data['user'])
for user in test_user:
# user = 0
visited_item = list(train_data[train_data['user']==user]['item'])
# print('訪問過的item:',visited_item)
if len(visited_item)==0: #沒有訓練數據,跳過
continue
per_st = time.time()
testlist = list(test_data[test_data['user']==user]['item'].drop_duplicates()) #去重保留第一個
testlist = list(set(testlist)-set(testlist).intersection(set(visited_item))) #去掉訪問過的item
if len(testlist)==0: #過濾後爲空,跳過
continue
# print("對用戶",user)
valid_cnt = valid_cnt + 1 #有效測試數
poss = {}
for item in all_item:
if item in visited_item:
continue
else:
poss[item] = np.dot(nP[user],nQ[item])
# print(poss)
rankedlist,test_score = topk(poss,k)
# print("Topk推薦:",rankedlist)
# print("實際訪問:",testlist)
# print("單條推薦耗時:",time.time() - per_st)
AP_i,len_R,len_T,MRR_i,HITS_i= cal_indicators(rankedlist, testlist)
AP += AP_i
sum_R += len_R
sum_T += len_T
MRR += MRR_i
HITS += HITS_i
# print(test_score)
# print('--------')
# break
etime = time.time()
PRE = HITS/(sum_R*1.0)
REC = HITS/(sum_T*1.0)
MAP = AP/(valid_cnt*1.0)
MRR = MRR/(valid_cnt*1.0)
p_time = (etime-stime)/valid_cnt
print('評價指標如下:')
print('PRE@',k,':',PRE)
print('REC@',k,':',REC)
print('MAP@',k,':',MAP)
print('MRR@',k,':',MRR)
print('平均每條推薦耗時:',p_time)
with open('./Basic MF/result_'+dsname+'.txt','w') as f:
f.write('評價指標如下:\n')
f.write('PRE@'+str(k)+':'+str(PRE)+'\n')
f.write('REC@'+str(k)+':'+str(REC)+'\n')
f.write('MAP@'+str(k)+':'+str(MAP)+'\n')
f.write('MRR@'+str(k)+':'+str(MRR)+'\n')
f.write('平均每條推薦耗時@:'+str(k)+':'+str(p_time)+'\n')
結果分析
這裏的迭代次數就不像自己構造的小矩陣那樣設那麼高了,因爲時間成本比較高,且也並非迭代越多越好。
這裏擺一個多次運行TopK@10的結果,注意這裏的,別問,問就是試出來的。可以看出隨step的迭代次數,指標先增後減。
進階!靈魂拷問
拷問1. 矩陣分解的效果與什麼因素有關?
1) 與潛在因子矩陣P,Q的初始值有關。一開始看網上說直接(0,1)之間,但實際效果很差,後來調小了才變好,猜測是過大的話如果跑偏了(即本來不喜歡結果初值很高),梯度一直下降也救不回來,還有就是正態分佈可以有負數,這在潛在特徵裏是允許的,而(0,1)要變爲負數更加困難,都是正數變相地增加了分解地難度。
2) 與學習率α與正則參數λ有關。這個有兩種解決方式,歐皇之間隨便調,玄學調參。老實人可以選擇更好的梯度下降策略,SGD,ADAM等等。
3) 與潛在矩陣的維度d有關。這個可以有限範圍內的調試,如20~100,20一增。如果是打比賽的話還是看臉。
3) 與迭代次數steps有關。歐皇笑了,非酋哭了。
拷問2. 爲什麼steps越高,效果有可能反而不好?
在本實驗中,steps越高,損失e確實是在下降,但效果反而不好,用機器學習的理論來講,這就是出現了過擬合。其次,如果是0-1矩陣,從主觀上來分析,交互過和喜歡並非是絕對關係,因此越貼合原矩陣,並非就越喜歡。
拷問3. 評分矩陣和0-1矩陣,哪個效果好?
這個應該沒有絕對的比較,從本次實驗來講,下圖爲0-1矩陣分解的結果,是評分的更好。理論上看似如果評分信息是真是代表用戶偏好,那麼評分的會更好,實則用戶的偏好是在一直變化的,0-1矩陣更提供了泛化的可能,而評分限制的較死。更真實的情況在於用戶評分數據會過於稀疏,真實環境裏很難有效,而0-1交互數據更容易收集,也就更稠密。因此我個人是站0-1這邊的。
拷問4. 換個大點的數據集(ML1M),效果怎麼樣?
下圖爲參數不動,直接拿來用的結果。
1)參數需要重新調整。
2)時間開銷猛增,傳統矩陣分解本身就是一個時間開銷較大的算法,這與矩陣的構建方式、行列數量有關。
PS:求點贊、關注、打賞,畢竟這些我都沒啥-。-
完整代碼
# -*- coding: utf-8 -*-
"""
Created on Sat Oct 19 12:37:04 2019
@author: YLC
"""
import os
import numpy as np
import pandas as pd
import time
import math
def getUI(dsname,dformat): #獲取全部用戶和項目
st = time.time()
train = pd.read_csv(dsname+'_train.txt',header = None,names = dformat)
test = pd.read_csv(dsname+'_test.txt',header = None,names = dformat)
data = pd.concat([train,test])
all_user = np.unique(data['user'])
all_item = np.unique(data['item'])
train.sort_values(by=['user','item'],axis=0,inplace=True) #先按時間、再按用戶排序
num_user = max(all_user)+1
num_item = max(all_item)+1
rating = np.zeros([num_user,num_item])
for i in range(0,len(train)):
user = train.iloc[i]['user']
item = train.iloc[i]['item']
score = train.iloc[i]['rating']
# score = 1
rating[user][item] = score
if os.path.exists('./Basic MF'):
pass
else:
os.mkdir('./Basic MF')
train.to_csv('./Basic MF/train.txt',index = False,header=0)
test.to_csv('./Basic MF/test.txt',index = False,header=0)
np.savetxt('./Basic MF/rating.txt',rating,delimiter=',',newline='\n')
et = time.time()
print("get UI complete! cost time:",et-st)
def getData(dformat):
rating = np.loadtxt('./Basic MF/rating.txt',delimiter=',',dtype=float)
train = pd.read_csv('./Basic MF/train.txt',header = None,names = dformat)
test = pd.read_csv('./Basic MF/test.txt',header = None,names = dformat)
data = pd.concat([train,test])
all_user = np.unique(data['user'])
all_item = np.unique(data['item'])
return rating,train,test,all_user,all_item
def topk(dic,k):
keys = []
values = []
for i in range(0,k):
key,value = max(dic.items(),key=lambda x: x[1])
keys.append(key)
values.append(value)
dic.pop(key)
return keys,values
def cal_indicators(rankedlist, testlist):
HITS_i = 0
sum_precs = 0
AP_i = 0
len_R = 0
len_T = 0
MRR_i = 0
ranked_score = []
for n in range(len(rankedlist)):
if rankedlist[n] in testlist:
HITS_i += 1
sum_precs += HITS_i / (n + 1.0)
if MRR_i == 0:
MRR_i = 1.0/(rankedlist.index(rankedlist[n])+1)
else:
ranked_score.append(0)
if HITS_i > 0:
AP_i = sum_precs/len(testlist)
len_R = len(rankedlist)
len_T = len(testlist)
return AP_i,len_R,len_T,MRR_i,HITS_i
def matrix_factorization(R,P,Q,d,steps,alpha=0.002,lamda=2e-6):
Q=Q.T
sum_st = 0 #總時長
e_old = 0 #上一次的loss
flag = 1
for step in range(steps): #梯度下降結束條件:1.滿足最大迭代次數,跳出
st = time.time()
e_new = 0
for u in range(len(R)):
for i in range(len(R[u])):
if R[u][i]>0:
eui=R[u][i]-np.dot(P[u,:],Q[:,i])
for k in range(d):
P[u][k] = P[u][k] + alpha*eui * Q[k][i]- lamda *P[u][k]
Q[k][i] = Q[k][i] + alpha*eui * P[u][k]- lamda *Q[k][i]
cnt = 0
for u in range(len(R)):
for i in range(len(R[u])):
if R[u][i]>0:
cnt = cnt + 1
e_new = e_new + pow(R[u][i]-np.dot(P[u,:],Q[:,i]),2)
et = time.time()
print('step',step+1,'cost time:',et-st)
e_new = e_new / cnt
if step == 0: #第一次不算loss之差
e_old = e_new
continue
sum_st = sum_st + (et-st)
if e_new<1e-3:#梯度下降結束條件:2.loss過小,跳出
flag = 2
break
if e_old - e_new<1e-10:#梯度下降結束條件:3.loss之差過小,梯度消失,跳出
flag = 3
break
else:
e_old = e_new
print('---------Summary----------\n',
'Type of jump out:',flag,'\n',
'Total steps:',step + 1,'\n',
'Total time:',sum_st,'\n',
'Average time:',sum_st/(step+1.0),'\n',
"The e is:",e_new)
return P,Q.T
def train(rating,d,steps):
R = rating
N=len(R) #用戶數
M=len(R[0]) #項目數
P = np.random.normal(loc=0,scale=0.01,size=(N,d))
Q = np.random.normal(loc=0,scale=0.01,size=(M,d))
nP,nQ = matrix_factorization(R,P,Q,d,steps)
# nR=np.dot(nP,nQ.T)
np.savetxt('./Basic MF/nP.txt',nP,delimiter=',',newline='\n')
np.savetxt('./Basic MF/nQ.txt',nQ,delimiter=',',newline='\n')
def test(train_data,test_data,all_item,dsname,k):
nP = np.loadtxt('./Basic MF/nP.txt',delimiter=',',dtype=float)
nQ = np.loadtxt('./Basic MF/nQ.txt',delimiter=',',dtype=float)
PRE = 0
REC = 0
MAP = 0
MRR = 0
AP = 0
HITS = 0
sum_R = 0
sum_T = 0
valid_cnt = 0
stime = time.time()
test_user = np.unique(test_data['user'])
for user in test_user:
# user = 0
visited_item = list(train_data[train_data['user']==user]['item'])
# print('訪問過的item:',visited_item)
if len(visited_item)==0: #沒有訓練數據,跳過
continue
per_st = time.time()
testlist = list(test_data[test_data['user']==user]['item'].drop_duplicates()) #去重保留第一個
testlist = list(set(testlist)-set(testlist).intersection(set(visited_item))) #去掉訪問過的item
if len(testlist)==0: #過濾後爲空,跳過
continue
# print("對用戶",user)
valid_cnt = valid_cnt + 1 #有效測試數
poss = {}
for item in all_item:
if item in visited_item:
continue
else:
poss[item] = np.dot(nP[user],nQ[item])
# print(poss)
rankedlist,test_score = topk(poss,k)
# print("Topk推薦:",rankedlist)
# print("實際訪問:",testlist)
# print("單條推薦耗時:",time.time() - per_st)
AP_i,len_R,len_T,MRR_i,HITS_i= cal_indicators(rankedlist, testlist)
AP += AP_i
sum_R += len_R
sum_T += len_T
MRR += MRR_i
HITS += HITS_i
# print(test_score)
# print('--------')
# break
etime = time.time()
PRE = HITS/(sum_R*1.0)
REC = HITS/(sum_T*1.0)
MAP = AP/(valid_cnt*1.0)
MRR = MRR/(valid_cnt*1.0)
p_time = (etime-stime)/valid_cnt
print('評價指標如下:')
print('PRE@',k,':',PRE)
print('REC@',k,':',REC)
print('MAP@',k,':',MAP)
print('MRR@',k,':',MRR)
print('平均每條推薦耗時:',p_time)
with open('./Basic MF/result_'+dsname+'.txt','w') as f:
f.write('評價指標如下:\n')
f.write('PRE@'+str(k)+':'+str(PRE)+'\n')
f.write('REC@'+str(k)+':'+str(REC)+'\n')
f.write('MAP@'+str(k)+':'+str(MAP)+'\n')
f.write('MRR@'+str(k)+':'+str(MRR)+'\n')
f.write('平均每條推薦耗時@:'+str(k)+':'+str(p_time)+'\n')
if __name__ == '__main__':
dsname = 'ML100K'
dformat = ['user','item','rating','time']
getUI(dsname,dformat) #第一次使用後可註釋
rating,train_data,test_data,all_user,all_item = getData(dformat)
d = 40 #隱因子維度
steps = 10
k = 10
train(rating,d,steps)
test(train_data,test_data,all_item,dsname,k)