引言
信息大爆炸的互聯網時代,推薦系統是幫助人們更高效獲取信息的手段之一。從淘寶天貓的商品推薦,到頭條的信息流推薦,再到短視頻推薦,推薦系統已經滲透到我們生活的方方面面。作爲公衆號的開篇系列,我們將分享關於推薦系統的各種技術,從傳統的協同過濾,到深度學習在推薦領域的應用。總結多年工作實踐所得,幫助讀者更全面深入地瞭解推薦系統。
協同過濾是推薦系統最基礎的算法,它可以簡單分爲User-based CF和Item-based CF。ItemCF的核心思想是選擇當前用戶偏好的物品的相似物品作爲推薦結果。而UserCF是選擇當前用戶的相似用戶偏好的物品作爲這個用戶的推薦結果。這篇文章將介紹如何基於Spark用不到一百行的代碼實現相似物品的計算。
數據準備
推薦系統是由數據驅動的,在實際企業工作中,用戶行爲數據存儲在數據倉庫中。假設數據倉庫上有一張用戶行爲日誌表:t_user_interaction,它的DDL如下:
CREATE TABLE t_user_interaction(
`user_id` string COMMENT 'User ID',
`item_id` string COMMENT 'Item Id',
`action_time` bigint COMMENT '動作發生的時間')
PARTITIONED BY (
dt bigint)
通過Spark的SQL引擎很容易獲取我們需要的數據:
val sql =
s"""
|select
|user_id,
|item_id
|from t_user_interaction
|where dt>=${param.startDt} and dt<=${param.endDt}
""".stripMargin
val interactions = spark.sql(sql)
.rdd
.map(r => {
val userId = r.getAs[String]("user_id")
val itemId = r.getAs[String]("item_id")
(userId, itemId)
})
這裏我們設置了兩個參數:startDt
,endDt
,即一個滑動時間窗的開始時間和結束時間。實際生產環境,用戶的行爲日誌在連續不斷的產生,線上會不間斷的收集這些行爲日誌,然後按一定時間窗,比如一個小時,保存一次。ItemCF的計算任務也需要按一定時間滑動窗口週期運行,因爲會不斷有新的物品出現,系統需要儘可能快地計算出新物品的相似物品,才能在用戶對新物品產生新的行爲後儘快做出響應。
相似度計算
物品的相似度計算有很多公式,這裏我們以最常用的餘弦相似度爲例:
公式中表示第個用戶對物品的評分,同理。
在實際生產中用戶的顯式評分數據很少,大多是一些隱式反饋(implicit feedback)數據,比如點擊或者購買,所以我們用0或者1來表示用戶對物品的偏好程度。以新聞推薦爲例,1可以是用戶點擊了一篇文章,0表示給曝光了某篇文章但是用戶沒點擊,或者用戶根本沒見過這篇文章。上面的公式可以拆解成分子和分母兩部分:分子可以理解成是同時點擊了文章和文章的用戶數。分母包含點擊了文章的用戶數和點擊了文章的用戶數。
首先,我們計算好每個文章的點擊數備用。
// 統計每個文章的點擊用戶數
val itemClickCnt = interactions.map {
case (_, itemId) => (itemId, 1)
}.reduceByKey((a, b) => a + b)
接着計算每兩篇文章同時被點擊的次數。假設總共有篇文章,兩兩的組合數有。直接的思路是拿到每個物品的點擊用戶列表,然後兩兩組合,求出兩個點擊用戶列表的交集。這個思路比較容易理解,但是面臨計算量太大任務可能無法完成的問題。比如,就需要至少數十億量級的計算。在生產環境,文章的數量常常不止十萬的量級,某些業務場景下,物品的數量可能有百萬級甚至更多。實際上並非所有文章組合都有共現發生(文章A和文章B都被用戶X點擊了稱爲一次A和B的一次共現),即有些文章組合沒有被同一個用戶點擊過,這些文章組合的相似度爲0,對後續的推薦沒有作用,可以去掉。因此,我們可以只計算至少被一個用戶同時點擊過的文章組合。共現的基礎是一個用戶點了多篇文章,類似用Map-Reduce思想實現Word-Counter的方法,先收集每個用戶的點擊文章列表,然後羅列出兩兩文章的組合,再統計這些組合出現的次數。
// 統計每兩個Item被一個用戶同時點擊出現的次數
val coClickNumRdd = interactions.groupByKey
.filter {
case (_, items) =>
items.size > 1 && items.size < param.maxClick // 去掉點擊數特別多用戶,可能是異常用戶
}
.flatMap {
case (_, items) =>
(for {x <- items; y <- items} yield ((x, y), 1))
}
.reduceByKey((a, b) => a + b)
.filter(_._2 >= param.minCoClick) // 限制最小的共現次數
注意把點擊次數特別多的用戶過濾掉,這些用戶可能是網絡的一些爬蟲,會污染數據。同時,這個操作也解決了數據傾斜導致計算耗時太長或無法完成的問題。(數據傾斜是Spark計算任務常見的問題,可以理解爲由於數據分佈的不均勻,某些子任務計算耗時太長或一直無法完成,導致整個任務耗時太長或無法完成。)另外,還需要限制文章最小的共現次數,如果A和B兩篇文章只是被一個用戶同時點擊了,不管計算出來的相似分數多高都不足以作爲相似的理由,很有可能只是偶然發生的。一般來說,被更多用戶同時點擊,相似的分數會更加置信。
通過上面兩步的操作,我們就完成了分子分母所需元素的計算。下面將他們合起來就可以計算相似度了。
val similarities = coClickNumRdd.map {
case ((x, y), clicks) =>
(x, (y, clicks))
}.join(itemClickNumRdd)
.map {
case (x, ((y, clicks), xClickCnt)) =>
(y, (clicks, x, xClickCnt))
}.join(itemClickNumRdd)
.map {
case (y, ((clicks, x, xClickCnt), yClickCnt)) =>
val cosine = clicks / math.sqrt(xClickCnt * yClickCnt)
(x, y, cosine)
}
得到物品之間的相似度後做一個簡單的排序,截取最相似的個物品,來作爲線上的推薦的數據。
到這裏相似物品的計算過程就完成了,完整的代碼可以在GitHub上找到。GitHub鏈接:https://github.com/Play-With-AI/recommender-system
總結
這篇文章用不到一百行的代碼實現了大數據場景下真實可用的ItemCF算法。讀者可以稍作修改應用於實際的業務。限於篇幅,很多細節並沒有詳細展開,比如不同相似度公式的比較,數據傾斜問題等。在後續的文章裏,我們將做相應的補充。ItemCF是推薦系統最基本最簡單但也不可或缺的算法,後續我們會繼續分享其他推薦算法的原理和實現。