內容來源《推薦系統實踐》
目前爲止都是在討論TopN推薦,即給定一個用戶,如何給他生成一個長度爲N的推薦 列表,使該推薦列表能夠儘量滿足用戶的興趣和需求。本書之所以如此重視TopN推薦,是因爲 它非常接近於滿足實際系統的需求,實際系統絕大多數情況下就是給用戶提供一個包括N個物品 的個性化推薦列表。 但是,很多從事推薦系統研究的同學最早接觸的卻是評分預測問題。從GroupLens到Netflix Prize到Yahoo! Music的KDD Cup,評分預測問題都是推薦系統研究的核心。評分預測問題最基本 的數據集就是用戶評分數據集。該數據集由用戶評分記錄組成,每一條評分記錄是一個三元組(u, i, r),表示用戶u 給物品 i 賦予了評分 r,本章用 表示用戶 u 對物品 i 的評分。因爲用戶不可能對所 有物品都評分,因此評分預測問題就是如何通過已知的用戶歷史評分記錄預測未知的用戶評分記 錄。表8-1是一個評分預測問題的例子,在該例子中每個用戶都對一些電影給出了評分,比如用 戶A給《虎口脫險》評了1分,給《唐山大兄》評了5分,給《少林足球》評了4分,給《大話西 遊》評了5分。但是,每個用戶都沒有對所有電影評分,比如用戶A沒有給《變形金剛》和《黑 客帝國》評分。那麼,當用戶瀏覽網頁並看到《變形金剛》和《黑客帝國》時,我們希望能夠給 用戶一個分數表明我們認爲用戶是否會喜歡這部電影,而這個分數也可以幫助用戶決策是否要看 這部電影,而如何提高這個分數的預測精度就是評分預測要解決的主要問題
離線實驗方法:
評分預測問題基本都通過離線實驗進行研究。在給定用戶評分數據集後,研究人員會將數據 集按照一定的方式分成訓練集和測試集,然後根據測試集建立用戶興趣模型來預測測試集中的用 戶評分。對於測試集中的一對用戶和物品(u, i),用戶u對物品i的真實評分是 ,而推薦算法預 測的用戶u對物品 i 的評分爲 ,那麼一般可以用均方根誤差RMSE度量預測的精度:
評分預測的目的就是找到最好的模型最小化測試集的RMSE。 關於如何劃分訓練集和測試集,如果是和時間無關的預測任務,可以以均勻分佈隨機劃分數 據集,即對每個用戶,隨機選擇一些評分記錄作爲測試集,剩下的記錄作爲測試集。如果是和時 間相關的任務,那麼需要將用戶的舊行爲作爲訓練集,將用戶的新行爲作爲測試集。Netflix通過 如下方式劃分數據集,首先將每個用戶的評分記錄按照從早到晚進行排序,然後將用戶最後10% 的評分記錄作爲測試集,前90%的評分記錄作爲訓練集.
評分預測算法
平均值:
def PredictAll(records, user_cluster, item_cluster):
total = dict()
count = dict()
for r in records:
if r.test != 0:
continue
gu = user_cluster.GetGroup(r.user)
gi = item_cluster.GetGroup(r.item)
basic.AddToMat(total, gu, gi, r.vote)
basic.AddToMat(count, gu, gi, 1)
for r in records:
gu = user_cluster.GetGroup(r.user)
gi = item_cluster.GetGroup(r.item)
average = total[gu][gi] / (1.0 * count[gu][gi] + 1.0)
r.predict = average
"""
在這段代碼中,user_cluster.GetGroup函數接收一個用戶ID,然後根據一定的算法返回
用戶的類別。item_cluster.GetGroup函數接收一個物品的ID,然後根據一定的算法返回物品的類
別。total[gu][gi]/count[gu][gi]記錄了第gu類用戶給第gi類物品評分的平均分。
上文提到,user_cluster和item_cluster有很多不同的定義方式,下面的Python代碼給
出了不同的user_cluster和item_cluster定義方式。其中,Cluster是基類,對於任何用戶和
物品,它的GetGroup函數都返回0,因此如果user_cluster和item_cluter都是Cluster類型,
那麼最終的預測函數就是全局平均值。IdCluster的GetGroup函數接收一個ID,會返回這個ID,
那麼如果user_cluster是Cluster類型,而item_cluster是IdCluster類型,那麼最終的預
測函數給出的就是物品平均值。以此類推,表8-2展示了MovieLens數據集中利用不同平均值方法
得到的RMSE,實驗結果表明對用戶使用UserVoteCluster,對物品採用ItemVoteCluster,
可以獲得最小的RMSE。
"""
class Cluster:
def __init__(self,records):
self.group = dict()
def GetGroup(self, i):
return 0
class IdCluster(Cluster):
def __init__(self, records):
Cluster.__init__(self, records)
def GetGroup(self, i):
return i
class UserActivityCluster(Cluster):
def __init__(self, records):
Cluster.__init__(self, records)
activity = dict()
for r in records:
if r.test != 0:
continue
basic.AddToDict(activity, r.user, 1)
k = 0
for user, n in sorted(activity.items(), \
key=itemgetter(1), reverse=False):
c = int((k * 5) / (1.0 * len(activity)))
self.group[user] = c
k += 1
def GetGroup(self, uid):
if uid not in self.group:
return -1
else:
return self.group[uid]
class ItemPopularityCluster(Cluster):
def __init__(self, records):
Cluster.__init__(self, records)
popularity = dict()
for r in records:
if r.test != 0:
continue
basic.AddToDict(popularity, r.item, 1)
k = 0
for item, n in sorted(popularity.items(), \
key=itemgetter(1), reverse=False):
c = int((k * 5) / (1.0 * len(popularity)))
self.group[item] = c
k += 1
def GetGroup(self, item):
if item not in self.group:
return -1
else:
return self.group[item]
class UserVoteCluster(Cluster):
def __init__(self, records):
Cluster.__init__(self, records)
vote = dict()
count = dict()
for r in records:
if r.test != 0:
continue
basic.AddToDict(vote, r.user, r.vote)
basic.AddToDict(count, r.user, 1)
k = 0
for user, v in vote.items():
ave = v / (count[user] * 1.0)
c = int(ave * 2)
self.group[user] = c
def GetGroup(self, uid):
if uid not in self.group:
return -1
else:
return self.group[uid]
class ItemVoteCluster(Cluster):
def __init__(self, records):
Cluster.__init__(self, records)
vote = dict()
count = dict()
for r in records:
if r.test != 0:
continue
basic.AddToDict(vote, r.item, r.vote)
basic.AddToDict(count, r.item, 1)
k = 0
for item, v in vote.items():
ave = v / (count[item] * 1.0)
c = int(ave * 2)
self.group[item] = c
def GetGroup(self, item):
if item not in self.group:
return -1
else:
return self.group[item]
基於鄰域的方法
基於用戶的鄰域算法和基於物品的鄰域算法都可以應用到評分預測中。基於用戶的鄰域算法 認爲預測一個用戶對一個物品的評分,需要參考和這個用戶興趣相似的用戶對該物品的評分,即:
def UserSimilarity(records):
item_users = dict()
ave_vote = dict()
activity = dict()
for r in records:
addToMat(item_users, r.item, r.user, r.value)
addToVec(ave_vote, r.user, r.value)
addToVec(activity, r.user, 1)
ave_vote = {x:y/activity[x] for x,y in ave_vote.items()}
nu = dict()
W = dict()
for i,ri in item_users.items():
for u,rui in ri.items():
addToVec(nu, u, (rui - ave_vote[u])*(rui - ave_vote[u]))
for v,rvi in ri.items():
if u == v:
continue
addToMat(W, u, v, (rui - ave_vote[u])*(rvi - ave_vote[v]))
for u in W:
W[u] = {x:y/math.sqrt(nu[x]*nu[u]) for x,y in W[u].items()}
return W
def PredictAll(records, test, ave_vote, W, K):
user_items = dict()
for r in records:
addToMat(user_items, r.user, r.item, r.value)
for r in test:
r.predict = 0
norm = 0
for v,wuv in sorted(W[r.user].items(),key=itemgetter(1), reverse=True)[0:K]:
if r.item in user_items[v]:
rvi = user_items[v][r.item]
r.predict += wuv * (rvi - ave_vote[v])
norm += abs(wuv)
if norm > 0:
r.predict /= norm
r.predict += ave_vote[r.user]
基於物品的鄰域算法在預測用戶u對物品i的評分時,會參考用戶u對和物品i相似的其他物品 的評分,即:
這裏,S(i, K)是和i最相似的物品集合,N(u)是用戶u評過分的物品集合,是物品之間的相 似度, 是物品i的平均分。對於如何計算物品的相似度,文章比較了3種主要的相似度:
Sarwar利用MovieLens最小的數據集對3種相似度進行了對比,並將MAE作爲評測指標。實 驗結果表明利用修正後的餘弦相似度進行評分預測可以獲得最優的MAE。不過需要說明的是, 在一個數據集上的實驗並不意味着在其他數據集上也能獲得相同的結果。
隱語義模型與矩陣分解模型
最近這幾年做機器學習和數據挖掘研究的人經常會看到下面的各種名詞,即隱含類別模型 (Latent Class Model)、隱語義模型(Latent Factor Model)、pLSA、LDA、Topic Model、Matrix Factorization、Factorized Model。 這些名詞在本質上應該是同一種思想體系的不同擴展。在推薦系統領域,提的最多的就是潛 語義模型和矩陣分解模型。其實,這兩個名詞說的是一回事,就是如何通過降維的方法將評分矩 陣補全。 用戶的評分行爲可以表示成一個評分矩陣R,其中R[u][i]就是用戶u對物品i的評分。但是,用 戶不會對所有的物品評分,所以這個矩陣裏有很多元素都是空的,這些空的元素稱爲缺失值 (missing value)。因此,評分預測從某種意義上說就是填空,如果一個用戶對一個物品沒有評過 分,那麼推薦系統就要預測這個用戶是否是否會對這個物品評分以及會評幾分.
傳統的SVD分解
對於如何補全一個矩陣,歷史上有過很多的研究。一個空的矩陣有很多種補全方法,而我們 要找的是一種對矩陣擾動最小的補全方法。那麼什麼纔算是對矩陣擾動最小呢?一般認爲,如果 補全後矩陣的特徵值和補全之前矩陣的特徵值相差不大,就算是擾動比較小。所以,最早的矩陣 分解模型就是從數學上的SVD(奇異值分解)開始的。② 給定m個用戶和n個物品,和用戶對物品
Simon Funk的SVD分
由於上面的兩個缺點,SVD分解算法提出幾年後在推薦系統領域都沒有得到廣泛的關 注。直到2006年Netflix Prize開始後,Simon Funk在博客上公佈了一個算法①(稱爲Funk-SVD), 一下子引爆了學術界對矩陣分解類方法的關注。而且,Simon Funk的博客也成爲了很多學術論文 經常引用的對象。Simon Funk提出的矩陣分解方法後來被Netflix Prize的冠軍Koren稱爲Latent Factor Model(簡稱爲LFM)
代碼實現了學習LFM模型時的迭代過程。在LearningLFM函數中,輸入train是訓 練集中的用戶評分記錄,F是隱類的格式,n是迭代次數:
def LearningLFM(train, F, n, alpha, lambda):
[p,q] = InitLFM(train, F)
for step in range(0, n):
for u,i,rui in train.items():
pui = Predict(u, i, p, q)
eui = rui - pui
for f in range(0,F):
p[u][k] += alpha * (q[i][k] * eui - lambda * p[u][k])
q[i][k] += alpha * (p[u][k] * eui - lambda * q[i][k])
alpha *= 0.9
return list(p, q)
如上面的代碼所示,LearningLFM主要包括兩步。首先,需要對P、Q矩陣進行初始化,然後需要通過隨機梯度下降法的迭代得到最終的P、Q矩陣。在迭代時,需要在每一步對學習參數α 進行衰減(alpha *= 0.9),這是隨機梯度下降法算法要求的,其目的是使算法儘快收斂。如果 形象一點說就是,如果需要在一個區域找到極值,一開始可能需要大範圍搜索,但隨着搜索的進 行,搜索範圍會逐漸縮小。 初始化P、Q矩陣的方法很多,一般都是將這兩個矩陣用隨機數填充,但隨機數的大小還是 有講究的,根據經驗,隨機數需要和1/sqrt(F)成正比。下面的代碼實現了初始化功能
def InitLFM(train, F):
p = dict()
q = dict()
for u, i, rui in train.items():
if u not in p:
p[u] = [random.random()/math.sqrt(F) for x in range(0,F)]
if i not in q:
q[i] = [random.random()/math.sqrt(F) for x in range(0,F)]
return list(p, q)
# 而預測用戶u對物品i的評分可以通過如下代碼實現:
def Predict(u, i, p, q):
return sum(p[u][f] * q[i][f] for f in range(0,len(p[u]))
加入偏置項後的LFM:
def LearningBiasLFM(train, F, n, alpha, lambda, mu):
[bu, bi, p,q] = InitLFM(train, F)
for step in range(0, n):
for u,i,rui in train.items():
pui = Predict(u, i, p, q, bu, bi, mu)
eui = rui - pui
bu[u] += alpha * (eui - lambda * bu[u])
bi[i] += alpha * (eui - lambda * bi[i])
for f in range(0,F):
p[u][k] += alpha * (q[i][k] * eui - lambda * p[u][k])
q[i][k] += alpha * (p[u][k] * eui - lambda * q[i][k])
alpha *= 0.9
return list(bu, bi, p, q)
def InitBiasLFM(train, F):
p = dict()
q = dict()
bu = dict()
bi = dict()
for u, i, rui in train.items():
bu[u] = 0
bi[i] = 0
if u not in p:
p[u] = [random.random()/math.sqrt(F) for x in range(0,F)]
if i not in q:
q[i] = [random.random()/math.sqrt(F) for x in range(0,F)] return
list(p, q)
def Predict(u, i, p, q, bu, bi, mu):
ret = mu + bu[u] + bi[i]
ret += sum(p[u][f] * q[i][f] for f in range(0,len(p[u]))
return ret
考慮鄰域影響的LFM:
前面的LFM模型中並沒有顯式地考慮用戶的歷史行爲對用戶評分預測的影響。爲此,Koren 在Netflix Prize比賽中提出了一個模型,將用戶歷史評分的物品加入到了LFM模型中,Koren將 該模型稱爲SVD++。 在介紹SVD++之前,我們首先討論一下如何將基於鄰域的方法也像LFM那樣設計成一個可以學習的模型。其實很簡單,我們可以將ItemCF的預測算法改成如下方式:
def LearningBiasLFM(train_ui, F, n, alpha, lambda, mu):
[bu, bi, p, q, y] = InitLFM(train, F)
z = dict()
for step in range(0, n):
for u,items in train_ui.items():
z[u] = p[u]
ru = 1 / math.sqrt(1.0 * len(items))
for i,rui in items items():
for f in range(0,F):
z[u][f] += y[i][f] * ru
sum = [0 for i in range(0,F)]
for i,rui in items items():
pui = Predict()
eui = rui - pui
bu[u] += alpha * (eui - lambda * bu[u])
bi[i] += alpha * (eui - lambda * bi[i])
for f in range(0,F):
sum[k] += q[i][k] * eui * ru
p[u][k] += alpha * (q[i][k] * eui - lambda * p[u][k])
q[i][k] += alpha * ((z[u][k] + p[u][k]) * eui - lambda * q[i][k])
for i,rui in items items():
for f in range(0,F):
y[i][f] += alpha * (sum[f] - lambda * y[i][f])
alpha *= 0.9
return list(bu, bi, p, q)
加入時間信息:
模型融合
生一個新模型,按照一定的參數加到舊模型上去,從而使訓練集誤差最小化。不同的是,這裏每 次生成新模型時並不對樣本集採樣,針對那些預測錯的樣本,而是每次都還是利用全樣本集進行 預測,但每次使用的模型都有區別。 一般來說,級聯融合的方法都用於簡單的預測器,比如前面提到的平均值預測器。下面的 Python代碼實現了利用平均值預測器進行級聯融合的方法。
def Predict(train, test, alpha):
total = dict()
count = dict()
for record in train:
gu = GetUserGroup(record.user)
gi = GetItemGroup(record.item)
AddToMat(total, gu, gi, record.vote - record.predict)
AddToMat(count, gu, gi, 1)
for record in test:
gu = GetUserGroup(record.user)
gi = GetUserGroup(record.item)
average = total[gu][gi] / (1.0 * count[gu][gi] + alpha)
record.predict += average
表8-3展示了MovieLens數據集上對平均值方法採用級聯融合後的RMSE。如果和表8-2的結果 對比就可以發現,採用級聯融合後,測試集的RMSE從0.9342下降到了0.9202。由此可見,即使 是利用簡單的算法進行級聯融合,也能得到比較低的評分預測誤差。