推薦系統系列(一):不到百行代碼實現基於Spark的ItemCF計算

推薦系統系列(一):不到百行代碼實現基於Spark的ItemCF計算

引言

信息大爆炸的互聯網時代,推薦系統是幫助人們更高效獲取信息的手段之一。從淘寶天貓的商品推薦,到頭條的信息流推薦,再到短視頻推薦,推薦系統已經滲透到我們生活的方方面面。作爲公衆號的開篇系列,我們將分享關於推薦系統的各種技術,從傳統的協同過濾,到深度學習在推薦領域的應用。總結多年工作實踐所得,幫助讀者更全面深入地瞭解推薦系統。

協同過濾是推薦系統最基礎的算法,它可以簡單分爲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的計算任務也需要按一定時間滑動窗口週期運行,因爲會不斷有新的物品出現,系統需要儘可能快地計算出新物品的相似物品,才能在用戶對新物品產生新的行爲後儘快做出響應。

相似度計算

物品的相似度計算有很多公式,這裏我們以最常用的餘弦相似度爲例:
simX,Y=XYXY=i=1n(xiyi)i=1n(xi)2×i=1n(yi)2sim_{X,Y}=\frac{XY}{||X||||Y||}=\frac{ \sum_{i=1}^n(x_iy_i)}{\sqrt{\sum_{i=1}^n(x_i)^2}\times\sqrt{\sum_{i=1}^n(y_i)^2}}

公式中xix_i表示第ii個用戶對物品xx的評分,yiy_i同理。

在實際生產中用戶的顯式評分數據很少,大多是一些隱式反饋(implicit feedback)數據,比如點擊或者購買,所以我們用0或者1來表示用戶對物品的偏好程度。以新聞推薦爲例,1可以是用戶點擊了一篇文章,0表示給曝光了某篇文章但是用戶沒點擊,或者用戶根本沒見過這篇文章。上面的公式可以拆解成分子和分母兩部分:分子可以理解成是同時點擊了文章xx和文章yy的用戶數。分母包含點擊了文章xx的用戶數和點擊了文章yy的用戶數。

首先,我們計算好每個文章的點擊數備用。

// 統計每個文章的點擊用戶數
val itemClickCnt = interactions.map {
  case (_, itemId) => (itemId, 1)
}.reduceByKey((a, b) => a + b)

接着計算每兩篇文章同時被點擊的次數。假設總共有NN篇文章,兩兩的組合數有N(N1)/2N*(N-1)/2。直接的思路是拿到每個物品的點擊用戶列表,然後兩兩組合,求出兩個點擊用戶列表的交集。這個思路比較容易理解,但是面臨計算量太大任務可能無法完成的問題。比如N=100000N=100000,就需要至少數十億量級的計算。在生產環境,文章的數量常常不止十萬的量級,某些業務場景下,物品的數量可能有百萬級甚至更多。實際上並非所有文章組合都有共現發生(文章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)
      }

得到物品之間的相似度後做一個簡單的排序,截取最相似的KK個物品,來作爲線上的推薦的數據。
到這裏相似物品的計算過程就完成了,完整的代碼可以在GitHub上找到。GitHub鏈接:https://github.com/Play-With-AI/recommender-system

總結

這篇文章用不到一百行的代碼實現了大數據場景下真實可用的ItemCF算法。讀者可以稍作修改應用於實際的業務。限於篇幅,很多細節並沒有詳細展開,比如不同相似度公式的比較,數據傾斜問題等。在後續的文章裏,我們將做相應的補充。ItemCF是推薦系統最基本最簡單但也不可或缺的算法,後續我們會繼續分享其他推薦算法的原理和實現。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章