spark 协同过滤

536 阅读3分钟

原理

我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。

这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。

交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单?

那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。

在数学上,给定维度为(M,N)的交互矩阵 C,我们可以把它分解为两个矩阵 U 与 I 的乘积。其中,我们可以把 U 称作“用户矩阵”,它的维度为(M,K);而 I 可以看作是“物品矩阵”,它的维度是(K,N)。

在用户矩阵与物品矩阵中,K 是超参数,它是由开发者人为设定的。不难发现,对于用户矩阵 U 中的每一行, 都可以看作是用户的 Embedding,也即刻画用户的特征向量。同理,物品矩阵中的每一列,也都可以看作是物品的 Embedding,也即刻画物品的特征向量。

正所谓,万物皆可 Embedding。对于任何事物,一旦它们被映射到同一个向量空间,我们就可以使用欧氏距离或是余弦夹角等方法,来计算他们向量之间的相似度,从而实现上述各种相似性(用户与用户、物品与物品、用户与物品)的量化。

基于相似度计算,我们就可以翻着花样地去实现各式各样的推荐。比方说,对于用户 A 来说,首先搜索与他 / 她最相似的前 5 个用户,然后把这些用户喜欢过的物品(电影)推荐给用户 A,这样的推荐方式,又叫基于用户相似度的推荐。

再比如,对于用户 A 喜欢过的物品,我们搜索与这些物品最相似的前 5 个物品,然后把这些搜索到的物品,再推荐给用户 A,这叫做基于物品相似度的推荐。

甚至,在一些情况下,我们还可以直接计算用户 A 与所有物品之间的相似度,然后把排名靠前的 5 个物品,直接推荐给用户 A。

基于上述逻辑,我们还可以反其道而行之,从物品的视角出发,给物品(电影)推荐用户。不难发现,一旦完成 Embedding 的转换过程,我们就可以根据相似度计算来灵活地设计推荐系统。

那么,接下来的问题是,在 Spark MLlib 的框架下,我们具体要怎么做,才能从原始的互动矩阵,获得分解之后的用户矩阵、物品矩阵,进而获取到用户与物品的 Embedding,并最终设计出简易的推荐引擎呢?

按照惯例,我们还是先上代码,用代码来演示这个过程。

import org.apache.spark.sql.DataFrame
 
// rootPath表示数据集根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/ratings.csv"
 
var data: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
 
// 类型转换
import org.apache.spark.sql.types.IntegerType
import org.apache.spark.sql.types.FloatType
 
// 把ID类字段转换为整型,把Rating转换为Float类型
data = data.withColumn(s"userIdInt",col("userId").cast(IntegerType)).drop("userId")
data = data.withColumn(s"movieIdInt",col("movieId").cast(IntegerType)).drop("movieId")
data = data.withColumn(s"ratingFloat",col("rating").cast(IntegerType)).drop("rating")
 
// 切割训练与验证数据集
val Array(trainingData, testData) = data.randomSplit(Array(0.8, 0.2))

第一步,还是准备训练样本,我们从 ratings.csv 创建 DataFrame,然后对相应字段做类型转换,以备后面使用。第二步,我们定义并拟合模型,完成协同过滤中的矩阵分解。

import org.apache.spark.ml.recommendation.ALS
 
// 基于ALS(Alternative Least Squares,交替最小二乘)构建模型,完成矩阵分解
val als = new ALS()
.setUserCol("userIdInt")
.setItemCol("movieIdInt")
.setRatingCol("ratingFloat")
.setMaxIter(20)
 
val alsModel = als.fit(trainingData)

值得一提的是,在 Spark MLlib 的框架下,对于协同过滤的实现,Spark 并没有采用解析解的方式(数学上严格的矩阵分解),而是用了一种近似的方式来去近似矩阵分解。这种方式,就是 ALS(Alternative Least Squares,交替最小二乘)。

具体来说,给定交互矩阵 C,对于用户矩阵 U 与物品矩阵 I,Spark 先给 U 设定一个初始值,然后假设 U 是不变的,在这种情况下,Spark 把物品矩阵 I 的优化,转化为回归问题,不停地去拟合 I,直到收敛。然后,固定住物品矩阵 I,再用回归的思路去优化用户矩阵 U,直至收敛。如此反复交替数次,U 和 I 都逐渐收敛到最优解,Spark 即宣告训练过程结束。

因为 Spark 把矩阵分解转化成了回归问题,所以我们可以用回归相关的度量指标来衡量 ALS 模型的训练效果,如下所示。


import org.apache.spark.ml.evaluation.RegressionEvaluator
 
val evaluator = new RegressionEvaluator()
// 设定度量指标为RMSE
.setMetricName("rmse")
.setLabelCol("ratingFloat")
.setPredictionCol("prediction")
 
val predictions = alsModel.transform(trainingData)
// 计算RMSE
val rmse = evaluator.evaluate(predictions)

验证过模型效果之后,接下来,我们就可以放心地从模型当中,去获取训练好的用户矩阵 U 和物品矩阵 I。这两个矩阵中,保存的正是用户 Embedding 与物品 Embedding。


alsModel.userFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
 
alsModel.userFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[0.53652495, -1.0...|
+---+--------------------+
*/
 
alsModel.itemFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
 
alsModel.itemFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[1.1281404, -0.59...|
+---+--------------------+
*/

就像我们之前说的,有了用户与物品的 Embedding,我们就可以灵活地设计推荐引擎。如果我们想偷懒的话,还可以利用 Spark MLlib 提供的 API 来做推荐。具体来说,我们可以通过调用 ALS Model 的相关方法,来实现向用户推荐物品,或是向物品推荐用户,如下所示。


// 为所有用户推荐10部电影
val userRecs = alsModel.recommendForAllUsers(10)
 
// 为每部电影推荐10个用户
val movieRecs = alsModel.recommendForAllItems(10)
 
// 为指定用户推荐10部电影
val users = data.select(als.getUserCol).distinct().limit(3)
val userSubsetRecs = alsModel.recommendForUserSubset(users, 10)
 
// 为指定电影推荐10个用户
val movies = data.select(als.getItemCol).distinct().limit(3)
val movieSubSetRecs = alsModel.recommendForItemSubset(movies, 10)

工作实践

# -*-coding:utf-8 -*-
import os
import argparse
from pyspark import SparkConf
from pyspark.sql import SparkSession
from pyspark.sql.types import *
from pyspark.sql import functions as F
from pyspark.ml.recommendation import ALS, ALSModel
from pyspark.ml.evaluation import RegressionEvaluator
from pyspark.ml.feature import OneHotEncoder, StringIndexer
from pyspark.sql.functions import explode
from pyspark.sql.functions import get_json_object, to_json, col, udf, lit

class CollaborativeFiltering(object):
    def __init__(self, spark_session):
        self.spark_session = spark_session
        self.model = None

    def train(self, train_set, user_col, item_col, rating_col, epoch=10):
        """
        Build the recommendation model using ALS on the training data
        Note we set cold start strategy to 'drop' to ensure we don't get NaN evaluation metrics
        """
        als = ALS(regParam=0.01, maxIter=epoch, userCol=user_col, itemCol=item_col, ratingCol=rating_col,
                  coldStartStrategy='drop')
        self.model = als.fit(train_set)

    def eval(self, test_set, label_col='ratingFloat', metric='rmse'):
        """ Evaluate the model on the test data """
        predictions = self.model.transform(test_set)

        # self.model.itemFactors.show(10, truncate=False)
        # self.model.userFactors.show(10, truncate=False)
        evaluator = RegressionEvaluator(predictionCol="prediction", labelCol=label_col, metricName=metric)
        loss = evaluator.evaluate(predictions)
        return loss

    def save(self, model_dir):
        self.model.write().overwrite().save(model_dir)

    def load(self, model_dir):
        self.model = ALSModel.load(model_dir)

    def recommend_for_all_users(self, num_items=10):
        user_recs = self.model.recommendForAllUsers(numItems=num_items)
        return user_recs

    def recommend_for_all_items(self, num_users=10):
        item_recs = self.model.recommendForAllItems(numUsers=num_users)
        return item_recs

    def recommend_for_user_subset(self, dataset, num_items=10):
        user_recs = self.model.recommendForUserSubset(dataset=dataset, numItems=num_items)
        return user_recs

    def recommend_for_item_subset(self, dataset, num_users=10):
        item_recs = self.model.recommendForItemSubset(dataset=dataset, numUsers=num_users)
        return item_recs

model_path = "/home/byte_dm_bytelingo/data/recommend/rec20/cf.model"
train_flag = True
epoch = 20

spark_session = SparkSession \
        .builder \
        .enableHiveSupport() \
        .config("hive.exec.dynamic.partition", "true") \
        .config("hive.exec.dynamic.partition.mode", "nonstrict") \
        .getOrCreate()

sql ='''
select  t1.user_id,
        t2.id as user_id_index,
        cast(material_id as string) as material_id,
        score
from    dm_bytelingo.rec20_user_album_history_score as t1
left join
        dm_bytelingo.ods_user_dict_hourly as t2
on      t1.user_id = t2.user_id
where   t1.date = '${date}'
and     t1.hour = '${hour}'
and     t2.date = '${date}'
and     t2.hour = '${hour}'
'''

df_raw = spark_session.sql(sql)
stringIndexer = StringIndexer(inputCol="material_id", outputCol="material_id_index")
stringIndexerModel = stringIndexer.fit(df_raw)
df_raw = stringIndexerModel.transform(df_raw)
df_raw.show(20, False)
print(df_raw.schema)
# 索引
user_map = df_raw.groupBy("user_id_index").agg(F.max("user_id").alias("user_id"))
user_map.show(20, False)
material_map = df_raw.groupBy("material_id_index").agg(F.max("material_id").alias("material_id"))
material_map.show(20, False)

training, test = df_raw.randomSplit((0.99, 0.01), seed=2022)

# collaborative filtering start
cf = CollaborativeFiltering(spark_session=spark_session)

if train_flag is True:
    cf.train(train_set=training,
                user_col='user_id_index',
                item_col='material_id_index',
                rating_col='score',
                epoch=epoch)

    cf.save(model_dir=model_path)
else:
    cf.load(model_dir=model_path)

loss = cf.eval(test_set=test, label_col='score', metric='rmse')
print("[Root-mean-square error] {}".format(loss))

# Generate top 30 movie recommendations for each user
user_recs = cf.recommend_for_all_users(num_items=200)
user_recs.show(10, False)
print(user_recs.schema)
df = user_recs.select(user_recs.user_id_index, explode(user_recs.recommendations).alias('pair'))
df.show(10, False)
print(df.schema)
df = df.select(df.user_id_index, df.pair.material_id_index.alias('material_id_index'))
df.show(10, False)

df = df.join(user_map, (df.user_id_index == user_map.user_id_index), "left")
df = df.join(material_map, (df.material_id_index == material_map.material_id_index), "left")
df = df.select(df.user_id, df.material_id)
df.show(10, False)

group_df = df.groupBy(F.col("user_id")).agg(
    F.concat_ws(",", F.collect_list(F.col("material_id"))).alias("recommend_materials")
)
group_df.show(10, False)

res = group_df.select(group_df.user_id, group_df.recommend_materials)
res = res.withColumn("date", lit('${date}'))
res = res.withColumn("hour", lit('${hour}'))

res.write.option("partitionOverwriteMode", "dynamic").insertInto('dm_bytelingo.rec20_user_cf_hourly')

spark_session.stop()