今天我們來聊一聊LFM(Latent Factor Model)的故事,這也算是我們在推薦系統裏第一個用到的學習算法了吧,前面講的兩個協同過濾都是基於統計來的。
協同過濾的思路就是基於用戶和物品的交互行爲,要麼計算用戶間的相似度,推薦相似度高的用戶喜歡的物品,因爲這兩個用戶可能興趣相投;要麼就是計算物品間的相似度,推薦和歷史記錄相似度很高的物品,因爲他們可能屬於同一類別的商品。我們做決策的基礎都是默認了商品是有類別的,可能有的用戶都喜歡某一類商品,所以這些用戶之間相似度高,可能有的商品是屬於同一類別的,因此這些商品的相似度很高。那既然這樣,有沒有可能直接得到商品的類別呢?這樣我們就可以直接根據類別去進行推薦了~
LFM就是基於這樣的想法,假設商品存在若干個種類,那麼每個用戶對每個類會有一個興趣度,同樣的,每個類內又有若干種商品,每個商品在這個類內又會有一個對應的權重。這樣,對於任何一個用戶-商品對,我們都可以用下述公式來表達
OK,那麼下一步就是去計算矩陣P和Q,最常見的方法就是在訓練集上不停迭代更新參數直至參數收斂。既然需要迭代,那我們得有一個損失函數或者目標函數作爲我們迭代的依據,這裏我們選用的是最常見的預測值和實際值差值的平方和,並且加上了正則項防止過擬合,具體如下
熟悉機器學習的同學應該很清楚,接下來就是基於梯度下降去優化這個損失函數,這裏就不贅述了。但是我們還有一個問題,對於顯式反饋的數據,我們直接用評分數據作爲訓練集,但是對於隱式反饋而言,只有正樣本,我們可以把這些數據標註爲1,但同時我們也需要負樣本,所以往往需要我們從所有樣本里選擇樣本構建負樣本集。這裏在構建負樣本的時候有一個小小的trick,要儘量選擇那些非常熱門但是用戶卻沒有發生交互的物品,因爲這樣構建的樣本往往更具有代表性,也有利於我們的training。在movielens數據集中,我們以樣本出現的次數作爲權重,隨機選擇樣本構建負樣本集,實現如下
def Random_Negative_Sampling(self, items):
ret = {}
for i in items:
ret[i] = 1
count = 0
for i in range(0, len(items)*5):
item = self.items_pool[random.randint(0, len(self.items_pool)-1)]
if item in items:
continue
ret[item] = 0
count += 1
if count > 2*len(items):
break
return ret
在實際實現LFM算法的過程中,自己遇到了一個大坑,提出來希望大家注意。
先問問你,如何去計算一個用戶對一個物品的喜愛程度?是不是直接用上面那個公式,用用戶對類的喜愛程度和該物品在此類中的權重乘積的累加呀。那麼恭喜你,如果這樣做,效果非常不好,一是太難收斂,二是預測效果總是不好。爲啥呢?實際預測的矩陣裏只有0和1兩個值,而Preference的計算則是一個實數,在梯度下降的時候要用到這兩者的差值,而這個值變動的範圍太大,一方面導致你基本很難收斂,另一方面超參數的選擇是個非常頭疼的問題。所以,在計算喜好度的時候,一定要給結果
加個sigmoid!!!
加個sigmoid!!!
加個sigmoid!!!
這樣把你的結果約束到0和1之間,防止在計算誤差的時候出現太大的波動從而影響收斂。這裏我在movielen的數據集上也進行了實驗,《推薦系統實戰》那本書裏選擇的隱類個數爲100,本來是想復現他的結果,不過我發現算起來實在是太慢了,所以我這裏選擇隱類F=10,學習率alpha=0.03,正則項稀疏lambda=0.01,在所有的用戶上迭代了30輪次,最後在測試集上的表現如下
召回率6.91% 精確率 23.2% 覆蓋率 40.8% 流行度 5.20。
按照常理和書上所說,基於學習的這種方法的表現應該比基於統計的協同過濾表現要好點,但是這裏的結果明顯比之前的最好結果要差些,主要原因可能是這裏的隱類選的太少了,書中的實驗實際選擇的隱類數目爲100。但我發現隱類100的時候不僅每一輪計算的速度大大提升,並且收斂的速度也遠遠慢於隱類數目較少的時候,這也很容易理解,畢竟對應的需要優化的參數數目多了10倍,因此需要的訓練樣本也要更多,收斂自然更慢,有興趣的或者有時間的同學可以去嘗試一下。
然後在整個實現LFM的過程中,還有其他幾個小地方大家可以注意一下
- 參數的初始化。所有參數初始化爲0好像不是一個好選擇,總是無法收斂。一般的做法都是選擇在-1到1之間的隨機數。
- 構建負樣本和正樣本的比例。書中說這是一個影響模型效果很重要的參數,實際上可以這麼理解,相同輪次的迭代,負樣本比例越高,其實越多的訓練數據,自然模型的表達能力可能會更好。
- 超參數的選擇。在實際使用的過程中,我發現超參數的選擇對於LFM模型的表現真實至關重要,學習率過高直接導致沒法收斂,但是太低了收斂的速度又太慢了,所以超參數的選擇真的是一個非常頭疼的事。
- LFM的計算速度是真的很慢。每一輪迭代,要在每個用戶上迭代其所有的行爲記錄和我們構造的負樣本,導致計算真的非常耗時。而隱類個數的增加不僅導致需要優化的參數增加,對於數據的需求同樣提高,這樣直接導致收斂速度變得非常非常慢。
===============================================================================================最後是對LFM算法的一些思考,它的本質其實就是矩陣分解,將之前的用戶物品矩陣分解成了用戶-隱類矩陣和隱類-物品矩陣,然後目標是讓這兩個矩陣的乘積與原矩陣的殘差儘可能得小,同時引入了一些正則項防止過擬合。後來我發現其實LFM算法就是FunkSVD算法,是典型的基於矩陣分解的方法,後來又出現了基於它進行改進的方法。
BiasSVD。在FunkSVD的基礎上又引入了平均得分、用戶偏置項和物品偏置項,相比於FunkSVD又多了兩個需要優化的參數,但優化方法啥的都是一樣的。事實表明,由於考慮了這些偏置項,令BiasSVD在某些場景下變現會優異很多。
SVD++。在BiasSVD的基礎上又引入了用戶的隱式反饋,也就是用戶之前如果對該物品產生過交互,則對它的評分要進行修正。我的媽呀,越來越複雜,算得也是越來越慢,之前在知乎上看到說雖然SVD++之前在比賽裏大放異彩,但實際好像根本沒人用……計算代價太大,沒法實時推薦,並且可解釋性也不強,真是扎心了。
OK,LFM就聊到這裏,下一章我們講Item2Vec,終於要到深度部分了啊,蛤蛤蛤~
===============================================================================================
剛剛發佈了一下發現內容有點短啊,那我把LFM的完整代碼發出來吧,希望對大家有用
class LFM():
def __init__(self, data, F, seed):
self.train, self.test = self.train_test_split(data, seed)
self.F = F
self.items_pool, self.all_items, self.all_users = self.Helper()
self.item_popularity=self.Item_popularity()
self.P, self.Q = self.Initial_LFM_Par()
def train_test_split(self, data, seed):
train_set = {}
test_set = {}
for user, movies in data.groupby('user_id'):
movies = movies.sample(
frac=1, random_state=seed).reset_index(drop=True)
train = movies[:int(0.8*len(movies))]
test = movies[int(0.8*len(movies)):]
train_set[user] = set(train['movies_id'].tolist())
test_set[user] = set(test['movies_id'].tolist())
print('Data preparation finished')
return train_set, test_set
def data_update(self, data, seed):
self.train, self.test = self.train_test_split(data, seed)
def Helper(self):
items_pool = []
all_items = set()
all_users = set()
for u, items in self.train.items():
all_users.add(u)
for i in items:
items_pool.append(i)
all_items.add(i)
return items_pool, all_items, all_users
def Item_popularity(self):
item_popularity = {}
for item in self.items_pool:
if item not in item_popularity.keys():
item_popularity[item] = 0
item_popularity[item] += 1
return item_popularity
def Random_Negative_Sampling(self, items):
ret = {}
for i in items:
ret[i] = 1
count = 0
for i in range(0, len(items)*5):
item = self.items_pool[random.randint(0, len(self.items_pool)-1)]
if item in items:
continue
ret[item] = 0
count += 1
if count > 2*len(items):
break
return ret
def Initial_LFM_Par(self):
P = {}
Q = {}
for user in self.all_users:
P[user] = {}
for k in range(self.F):
P[user][k] = random.random()
for k in range(self.F):
Q[k] = {}
for item in self.all_items:
Q[k][item] = random.random()
return P, Q
def Predict(self, user, item):
pre = sum(self.P[user][k]*self.Q[k][item] for k in range(self.F))
return 1.0/(1+np.exp(-pre))
def Generate_sample(self):
sample={}
for user, items in self.train.items():
sample[user] = self.Random_Negative_Sampling(items)
return sample
def Parameter_Update2(self, N_step, alpha, lam, N):
sample=self.Generate_sample()
for step in range(N_step):
print('Now step %i' % step)
for user in self.all_users:
for item, rui in sample[user].items():
eui = rui-self.Predict(user, item)
for k in range(self.F):
self.P[user][k] += (eui*self.Q[k]
[item]-lam*self.P[user][k])*alpha
self.Q[k][item] += (eui*self.P[user]
[k]-lam*self.Q[k][item])*alpha
if (step+1)%10==0:
recall, precision, coverage, popularity=self.eval_fun(N)
alpha *= 0.95
def Parameter_Update(self, N_step, alpha, lam, N):
for step in range(N_step):
print('Now step %i' % step)
for user, items in self.train.items():
sample = self.Random_Negative_Sampling(items)
for item, rui in sample.items():
eui = rui-self.Predict(user, item)
for k in range(self.F):
self.P[user][k] += (eui*self.Q[k]
[item]-lam*self.P[user][k])*alpha
self.Q[k][item] += (eui*self.P[user]
[k]-lam*self.Q[k][item])*alpha
if (step+1)%10==0:
recall, precision, coverage, popularity=self.eval_fun(N)
alpha *= 0.95
def Recommend(self, user):
rank = {}
already_items = self.train[user]
for item in self.all_items:
if item in already_items:
continue
rank[item]=self.Predict(user,item)
return rank
def Get_Recommendation(self, user,N):
rank = self.Recommend(user)
recommend_list = []
for i, score in sorted(rank.items(), key=itemgetter(1), reverse=True)[:N]:
recommend_list.append(i)
return recommend_list
def eval_fun(self,N):
hit = 0
all_recall = 0
all_precision = 0
recommend_items = set()
ret = 0
n = 0
for user in self.all_users:
tu = self.test[user]
recommend_list = self.Get_Recommendation(user,N)
for item in recommend_list:
recommend_items.add(item)
ret += np.log(1+self.item_popularity[item])
n += 1
if item in tu:
hit += 1
all_recall += len(tu)
all_precision += N
print(hit)
recall = hit/(all_recall*1.0)
precision = hit/(all_precision*1.0)
coverage = len(recommend_items)/(len(self.all_items)*1.0)
popularity = ret/(n*1.0)
print(recall, precision, coverage, popularity)
return recall, precision, coverage, popularity