redis常用demo收集(二)——基於redis的簡單使用者協同推薦

liangxingwei發表於2018-07-21

思路

假設有使用者A的喜好列表SA[1,2,3]和使用者B的喜好列表SB[3,4,5],通過len(SA^SB)/len(SA)就能簡單地得到一個能代表B相對於A的相似度,雖然不一定很精準,但勝在簡單。

127.0.0.1:6379> sinter a:like b:like
1) "3"

len(SA^SB)=1 
len(SA)=3
因而相似度為1/3
複製程式碼

對於某個使用者A,用A對其他使用者C[2,3,6],D[1,2,3,4],E[1,2,3,4,5,6,7],求相似度,通過對列表排序,就能得到關於A的相似度優先列表[D,E,C,B]

相似度分別為2/3,1,1(此處有點問題,遠多於這種情形應該是不怎麼相似才對)
因而排序是D,E,C,B
複製程式碼

使用差集操作,B相對於A的差集就是除並集外B的剩餘部分,此場景下通俗點來講就是從相似好友中推薦A可能會喜歡的物品。 sdiff key1 key2,求出key1相對於key2的差集

127.0.0.1:6379> sdiff d:like a:like
1) "4"
127.0.0.1:6379> sdiff e:like a:like
1) "4"
2) "5"
3) "6"
4) "7"
127.0.0.1:6379> sdiff c:like a:like
1) "6"
127.0.0.1:6379> sdiff b:like a:like
1) "4"
2) "5"
(integer) 2
複製程式碼

這個時候,將所有的差集求並集之後就能得到使用者a的推薦列表了

簡單實現

用golang做了一個簡單的實現版本,程式碼放在

實現過程遇到的比較有趣點分別是,相似度的計算邏輯,怎麼得到某個使用者與所有好友差集的全集,以及如何給使用者推薦物品

相似度的計算邏輯

按照思路,本意是通過len(SA^SB)/len(SA)來算,但是考慮到SA較少而SB較多甚至SB完全覆蓋了SA的場景,不能那麼簡單就算了,羅列一下發現有以下情形

  • A和B的元素幾乎沒重合,例如[1,3,5,7,9]和[2,3,6,8,10]
  • A和B的元素有部分重合,例如[1,3,5,7,9]和[2,3,5,7,10]
  • A和B的元素完全重合且B中存在少量不存在A的元素,例如[1,2,3]和[1,2,3,4]
  • A和B的元素完全重合且B中存在大量量不存在A的元素,例如[1,2,3]和[1,2,3,4,5,6,7,8]

若是簡單的通過len(SA^SB)/len(SA)來算,那麼第四種情況也會被當做很高相似度,這並不是正確的做法,於是就想到減去B中非交集的部分佔B元素的比例,即變為len(SA^SB)/len(SA) - len(SB-SA^SB)/len(SB)

雖然勉強能用,但是糾結了一下,如果len(SA^SB)/len(SA)的意義是交集能代表A的程度數值,-len(SB-SA^SB)/len(SB)代表除去B不能代表A的程度數值,那麼len(SA^SB)/len(SB),也是交集能代表B的程度數值,如果同時使用能代表A的程度數值與能代表B的程度數值,直覺更為精準

於是公式就變成了len(SA^SB)/len(SA)-len(SB-SA^SB)/len(SB)+len(SA^SB)/len(SB),最後各加個引數,來表示各個數值佔相似度的權重,變成了plen(SA^SB)/len(SA)-qlen(SB-SA^SB)/len(SB)+r*len(SA^SB)/len(SB)

然而到這,要得到一個很是精準的相似度計算演算法,真的不簡單,引數數值的選擇讓人頭疼,然而又缺乏一點必要的數學知識來推導這個值(每到這裡就羨慕研究生),於是就隨便測試了下,最後確定了一個做法

if float64(len(s))/float64(likeLen) >= 1 {
    similarity = 0.66 * float64(len(s))/float64(likeLen) - 0.38 *float64(friendLikeLen-len(s))/float64(friendLikeLen) + 0.45 * float64(len(s))/float64(friendLikeLen)
}else{
    similarity = float64(len(s))/float64(likeLen) - 0.3 *float64(friendLikeLen-len(s))/float64(friendLikeLen) + 0.35 * float64(len(s))/float64(friendLikeLen)
}
複製程式碼

likeLen為使用者喜好列表的長度,friendLikeLen為好友的喜好列表的長度,len(s)為交集的長度,程式碼大意就是若是B遠遠覆蓋了A,則採用p=0.66,q=0.38,r=0.45,若B沒覆蓋到A,則p=1,q=0.3,r=0.35

得到某個使用者與所有好友差集的全集

根據相似度對好友排序之後,遍歷好友列表,對每個好友與使用者求差集,求出的差集應如何得到一個給使用者推薦的全集

首先想到的是暴力方法,每取到一個差集,就使用redis sadd命令,虛擬碼如下

for friend := friends {
    list := redis.sdiff(friend, user)
    
    redis.add(user:recommend, list)
}

複製程式碼

這樣既能保證次序,實現起來又很簡單,但是缺點就是使用者存在多少個好友,就得操作多少次redis,這實在不是一個好選擇

於是在這個基礎上,想到一個方法,先把差集都存放到一個slice中,最後for迴圈退出時只需要執行一次sadd即可,虛擬碼如下

for friend := friends {
    list := redis.sdiff(friend, user)
    recommendList.append(list)
}
redis.sadd(recommendList)
複製程式碼

仔細想了下,假設使用者的好友數量很大,好友的喜好列表也很大,各個好友間的喜好情況幾乎一樣,那麼在控制記憶體不爆滿的情況,很極端的時候會出現存放到redis的使用者的推薦列表,並沒有多少個選項.

比如[1,2,3....,1,2,3],n個123,最後得到的使用者推薦列表只有1,2,3,於是明白還是得在程式碼裡得到一個set

想到了用map[int]struct和[]int結合的辦法,把物品ID作為key,以此甄別是否已存在元素(若是直接用slice則需要每次都遍歷slice檢驗存在,時間複雜度比較高),元素放置到slice中,最後redis.add

m := make(map[string]*struct{},len(simFriends))
	indexes := make([]string,0,len(simFriends))
for _, friend := range simFriends {

    redisClient.Append("sdiff", fmt.Sprintf(userLikeKey, friend.ID), fmt.Sprintf(userLikeKey, userID))
    r := redisClient.GetReply()
    if err := r.Err; err != nil {
        panic(err)
    }
    values, err := r.List()
    if err != nil {
        panic(err)
    }

    for _, val := range values {
        if m[val] != nil {
            continue
        }
        m[val] = &struct{}{}
        indexes = append(indexes, val)
        if len(indexes) >= 500 {
            break
        }
    }
}
redisClient.Append("lpush", fmt.Sprintf(userRecommendKey,userID), indexes)
複製程式碼

一種空間換時間的做法,保證了推薦的次序,一定程度上也保證了推薦的個數,仔細的你可能看到了,沒用sadd命令,原因在下節

給使用者推薦物品

想到的推薦場景有兩種,一種是類似網易雲私人FM的一個一個的推薦,一個是類似淘寶京東這種猜你喜歡幾個幾個的推薦

當使用redis set的資料結構,雖然能做到一個一個地推薦,但是要麼就是srandmember命令,隨機成員,沒有按相似度排序,要麼就是Spop key 1,這個推薦了一次就不會在推薦第二次,或許不是很友好

然後想到其實我程式碼裡面已經做了set的操作,存放在redis即便不是set也沒問題,用list結構,無論是lrange,lindex又或者很極端的用rpop都沒問題,可以說是很適合了,程式碼

func getUserRecommend(userID,pageSize,page int) []string{
	redisClient.Append("lrange", fmt.Sprintf(userRecommendKey,userID), pageSize*(page -1),pageSize*page - 1)
	r := redisClient.GetReply()
	if r.Err != nil {
		log.Println(r.Err)
		return nil
	}
	recommendList,err := r.List()
	if err != nil {
		log.Println(err)
		return nil
	}
	return recommendList
}
複製程式碼

總結

主要還是增加了對redis資料結構的適用場景的思考。此外越接觸興趣推薦,越是對研究生的研究課題感興趣,越是湧現讀在職的慾望,越是對數學界和計算機界的大神表示敬畏。

水平有限,歡迎拍磚

相關文章