数据科学

使用 Co-Visitation 矩阵和 RAPIDS cuDF 构建高效的推荐系统

推荐系统在跨各种平台实现个性化用户体验方面发挥着至关重要的作用。这些系统旨在根据用户过去的行为和偏好预测和推荐用户可能与之交互的商品。构建有效的推荐系统需要理解和利用庞大、复杂的数据集,这些数据集可捕获用户和商品之间的交互。

本文将向您展示如何基于共访问矩阵构建简单而强大的推荐系统。构建共访问矩阵的主要挑战之一是处理大型数据集时涉及的计算复杂性。使用像 pandas 等库的传统方法效率低下且速度缓慢,尤其是在处理数百万甚至数十亿次交互时。这正是 RAPIDS cuDF 的用武之地。RAPIDS cuDF 是一个 GPU DataFrame 库,提供了类似 pandas 的 API,用于加载、过滤和操作数据。

推荐系统和联合访问矩阵

推荐系统是一种机器学习算法,旨在为用户提供个性化建议或推荐。这些系统用于各种应用,包括电子商务(Amazon、OTTO)、内容流式传输(Netflix、Spotify)、社交媒体(Instagram、X、TikTok)等。这些系统的作用是帮助用户发现符合其兴趣和偏好的产品、服务或其他内容。

用于构建推荐系统的数据集通常包含以下内容:

  • N 要推荐的项目。N 可能非常大(甚至数百万)。
  • 用户与物品之间的交互。对于给定用户的这种交互序列称为会话。然后目标是推断用户将与下一个交互的物品。

图 1 显示了用户与项目 6543、242、5381 和 5391 进行交互的示例会话。推荐系统的目标是预测用户将与下一个交互的项目。评估性能的一种常见方法是使用模型对k进行的猜测,计算 Recall@k。模型可以通过真值项目数量归一化检索的真值项目数量来计算 Recall。 

A diagram showing an example session. Item 6543 is ordered first, then item 2424, then 5391, followed by item 5391. The next action is unknown.
图 1. 用于构建推荐器系统的示例会话

在会话期间,用户通常会与多个商品进行交互。协同访问矩阵会对一起出现的商品进行计数,大小为 N x N。通过检查哪些商品与会话中的商品同时频繁出现,可以轻松使用协同访问矩阵来提出建议。例如,在图 1 所示的会话中,如果商品 2834 经常与商品 6543 一起购买,则使用此矩阵提出建议是非常合适的。

构建共同访问矩阵所面临的挑战

计算共访问矩阵需要查看所有会话并计算所有并发次数。这很快就会产生高昂的成本:对于大小为  L 的给定会话,复杂性在 O(L^2) 中。对于真实世界的推荐系统数据集,您预计会有数百万个会话(如果不是数十亿)。因此,要使共访问矩阵可用,需要进行大量优化。同时还需要考虑会话。

pandas 使计算易于实现,但以牺牲效率为代价。为避免显存爆炸,一方面需要将会话拆分成多个部分。另一方面,大数据集会导致严重的减速。

需要更快速的计算框架,同时还能让pandas的代码更加清晰。这正是RAPIDS cuDF的用武之地。你可以使用RAPIDS cuDF将计算加速40倍,无需更改代码。

本文演示了如何使用 RAPIDS cuDF pandas 加速器模式构建共访问矩阵并加速工作流程。在阅读时运行代码将让您更好地了解加速器的益处。开始之前,请务必打开 GPU 加速器。有关更多详细信息,请参阅演示笔记本

RAPIDS cuDF pandas 加速器模式

RAPIDS cuDF 是一个 Python GPU DataFrame 库,旨在加速在 CPU 上对大型数据集执行缓慢的操作(例如加载、连接、聚合和过滤)。

其 API 风格与 pandas 类似,借助新的 cuDF pandas 加速器模式,您可以在不更改任何代码的情况下将加速计算引入 pandas 工作流程。cuDF 库为表格数据处理提供 50 倍到 150 倍的性能提升。

RAPIDS cuDF pandas 加速器模式扩展了 cuDF 库的功能,可将表格数据处理的性能提高 50 倍到 150 倍。它使您能够将加速计算引入 pandas 工作流程,而无需更改任何代码。

数据

本教程中使用的数据取自 OTTO – 多目标推荐系统 Kaggle 比赛的训练集,该比赛包含一个月的课程。前三周用于构建矩阵,最后一周用于评估。验证课程被截断以构建模型的目标。回想一下 20 将用于查看共访问矩阵检索截断项目的效果。

请注意,使用时间分割以避免信息泄露非常重要。测试数据包含数据集的第五周。数据集包含 186 万个项目,以及大约 5 亿用户与这些项目的交互。这些交互存储在 chunked parquet 文件中,以便于处理。

已知的数据相关信息如下:

  • session:会话ID;在本例中,会话相当于一个用户
  • aid:商品 ID
  • ts:交互发生的时间
  • type:交互的类型;可以是点击、购物车或订单
The image shows 5 rows of session 0.  First, item 1517085 was clicked, then items 1563459, 1309446, 16246 and 1781822 were clicked.
图 2.数据样本

实施共同访问矩阵

由于数据集中的项目数量众多,内存存在问题。因此,请将数据分为两部分,以防止共访问矩阵过大。然后,遍历训练数据中的所有拼接文件,以进行循环处理。

covisitation_matrix = []
for part in range(PARTS):
      print(f"- Part {part + 1}/{PARTS}")
      matrix = None
      for file_idx, file in enumerate(tqdm(train_files)):

第一步是加载数据。然后应用一些转换以节省内存:将列的类型更改为int32,并限制会话时间超过30次交互。

for part in range(PARTS):
     for file_idx, file in enumerate(tqdm(train_files)):
	      [...]
          # Load sessions & convert columns to save memory
          df = pd.read_parquet(file, columns=["session", "aid", "ts"])
          df["ts"] = (df["ts"] / 1000).astype("int32")
          df[["session", "aid"]] = df[["session", "aid"]].astype("int32")

          # Restrict to first 30 interactions
          df = df.sort_values(
               ["session", "ts"],
               ascending=[True, False],
               ignore_index=True
          )
          df["n"] = df.groupby("session").cumcount()
          df = df.loc[df.n < 30].drop("n", axis=1)

接下来,您可以通过在会话列上将数据聚合到自身来获取所有并发数据。这已经非常耗时,生成的数据帧非常大。

     # Compute pairs 
     df = df.merge(df, on="session")

为降低矩阵计算成本,请将项目限制为当前考虑的部分中的项目。此外,仅考虑在 1 小时内对不同项目进行的交互,不允许在会话中进行重复的交互。

     # Split in parts to reduce memory usage
     df = df.loc[
          (df["aid_x"] >= part * SIZE) &
          (df["aid_x"] < (part + 1) * SIZE)
     ]

     # Restrict to same day and remove self-matches
     df = df.loc[
          ((df["ts_x"] - df["ts_y"]).abs() < 60 * 60) & 
          (df.aid_x != df.aid_y)
     ]
	# No duplicated interactions within sessions
    df = df.drop_duplicates(
          subset=["session", "aid_x", "aid_y"],
          keep="first",
     ).reset_index(drop=True)

在下一步中,计算矩阵权重。通过对数据对进行求和聚合来计算所有的并发次数。

     # Compute weights of pairs
     df["wgt"] = 1
     df["wgt"] = df["wgt"].astype("float32")

     df.drop(["session", "ts_x", "ts_y"], axis=1, inplace=True)
     df = df.groupby(["aid_x", "aid_y"]).sum()

在第二个循环(循环遍历parquet文件)结束时,通过将新计算的权重添加到先前的权重来更新系数。由于共访问矩阵很大,因此此过程很慢,并且会消耗内存。要释放一些内存,请删除未使用的变量。

     # Update covisitation matrix with new weights
     if matrix is None:
          matrix = df
     else:  # this is the bottleneck operation
          matrix = matrix.add(df, fill_value=0)  

     # Clear memory
     del df
     gc.collect()

查看所有数据后,通过仅保留每个项目的 N 个最佳候选项(即具有最高权重的候选项)来减小矩阵大小。这是有趣信息所在的地方。

for part in range(PARTS):
     [...]
     # Final matrix : Sort values
     matrix = matrix.reset_index().rename(
          columns={"aid_x": "aid", "aid_y": "candidate"}
     )	
     matrix = matrix.sort_values(
          ["aid", "wgt"], ascending=[True, False], ignore_index=True
     )

     # Restrict to n candids
     matrix["rank"] = matrix.groupby("aid").candidate.cumcount()
     matrix = matrix[matrix["rank"] < N_CANDIDS].reset_index(drop=True)
     covisitation_matrix.append(matrix)

最后一步是连接矩阵中单独计算的不同部分。然后,如果需要,您可以选择将矩阵保存到磁盘。

covisitation_matrix = pd.concat(covisitation_matrix, ignore_index=True)

就这样,这段代码使用 pandas 计算简单的共访问矩阵。它确实有一个主要缺陷:速度很慢,计算矩阵大约需要 10 分钟!为了在合理的运行时间内,它还需要大幅限制数据。

cuDF pandas 加速器模式

这就是 cuDF pandas 加速器模式的用武之地。重启内核,只需一行代码即可释放您 GPU 的强大功能:

%load_ext cudf.pandas

表 1 显示了支持和不支持 cuDF 加速的运行时。一行代码实现了 40 倍的加速。

  pandas cuDF pandas
10%的数据 8 分钟 41 秒 13 秒
完整数据集 1 小时 30 分钟* 5 分钟 30 秒
表 1. pandas 与 cuDF 的性能比较

生成候选项

使用共访问矩阵生成候选项以推荐是其中一种应用。这是通过聚合会话中所有项目的共访问矩阵的权重来完成的。然后,将推荐权重最大的项目。在 pandas 中,实现非常简单,并且再次受益于 GPU 加速器。

首先加载数据,然后只考虑最后的N_CANDIDS看到的项目。在会议中已经看到的项目是很好的推荐。我们将首先推荐它们。请记住,自匹配是从共访问矩阵中删除的,以节省内存,因此只需在此处恢复它们即可。

candidates_df_list = []
last_seen_items_list = []

for file_idx in tqdm(range(len(val_files))):
     # Load sessions & convert columns to save memory
     df = pd.read_parquet(
          val_files[file_idx], columns=["session", "aid", "ts"]
     )
     df["ts"] = (df["ts"] / 1000).astype("int32")
     df[["session", "aid"]] = df[["session", "aid"]].astype("int32")

     # Last seen items
     df = df.sort_values(
          ["session", "ts"], ascending=[True, False], ignore_index=True
     )
     df["n"] = df.groupby("session").cumcount()

     # Restrict to n items
     last_seen_items_list.append(
          df.loc[df.n < N_CANDIDS].drop(["ts", "n"], axis=1)
      )
     df.drop(["ts", "n"], axis=1, inplace=True)

接下来,将会话中项目的共访问矩阵合并。然后,每个项目都会与 N(候选项、权重)对相关联。要获得候选项的会话级别权重,请计算候选项在会话项目上的权重总和。通过获取权重最大的 N_CANDIDS 候选项,您可以获得推荐系统的候选项。

for file_idx in tqdm(range(len(val_files))):
     [...]
     # Merge covisitation matrix
     df = df.merge(
          covisitation_matrix.drop("rank", axis=1), how="left", on="aid"
     )
     df = df.drop("aid", axis=1).groupby(
          ["session", "candidate"]
     ).sum().reset_index()

     # Sort candidates
     df = df.sort_values(["session", "wgt"], ascending=[True, False])

     # Restrict to n items
     df["rank"] = df.groupby("session").candidate.cumcount()
     df = df[df["rank"] < N_CANDIDS].reset_index(drop=True)
     candidates_df_list.append(df)

绩效评估

要评估候选对象的强度,请使用召回指标。召回指标衡量检索者在真值中成功找到的物品所占的比例。

在本例中,允许 20 个候选项。你希望查看用户购买的商品中成功检索矩阵的比例。实现的召回是 0.5868,这已经是强基准。在推荐系统返回的 20 个商品中,平均有 11 个是用户购买的。这已经是强基准:在比赛期间,排名靠前的团队的得分接近 0.7。请参阅演示笔记本,了解有关实现的详细信息,这超出了本文的讨论范围。

更进一步

通过加速共访问矩阵计算和聚合,您可以非常快速地进行迭代,从而改善候选者的回忆。第一个改进是为矩阵提供更多的历史记录。借助快速实现,无需等待数小时的计算结束。

然后优化矩阵。虽然您可以尝试各种方法,但目前,共访问矩阵会针对每个同时出现的商品给出一个权重,该演示为时间更近的商品提供了更多权重,因为此类交互似乎更相关。另一种想法是考虑交互的类型,一起购买的商品或一起添加到购物车的商品似乎比简单查看的商品更适合推荐。

到目前为止,在聚合矩阵权重时,会话中的所有项目都被视为同等重要。然而,在会话后期出现的项目的预测系数高于最早出现的项目。因此,可以通过在会话结束时增加项目候选项的权重来优化聚合,以提高预测准确性。

通过这三次更改,recall@20 将提升至 0.5992。实现遵循与本文中介绍的代码相同的方案,并添加了几行以实现改进。有关详细信息,请参阅演示 Notebook

最后一步是将多个共访问矩阵合并,以捕获不同类型的候选对象。

总结

本文提供了构建和优化共访问矩阵的全面的指南。尽管共访问矩阵是一个简单的工具——它们会计算用户会话中项目的共出现次数——但构建它们涉及处理大量数据。高效运行对于快速迭代和改进推荐系统是必要的。

通过利用 RAPIDS cuDF 及其新发布的 pandas 加速器模式,共访矩阵计算速度最高可提升至原来的 50 倍。得益于 GPU 加速,快速构建多样化的共访矩阵是 NVIDIA 的 Kaggle Grandmasters 赢得多项推荐系统竞赛(包括 KDDCup 2023 和 OTTO)的关键要素。

虽然演示notebook仅计算两种类型的矩阵,但可能性是无限的。尝试实验代码并尝试改进矩阵的构建,以捕获不同的候选项。

 

Tags