0%

阿里 RecIS:搜广推训练的背景与难点

离开米子后,从相机转行到了搜广推方向,所以需要了解一下搜广推的算法背景。搜索、广告、推荐这三个算法由于背景相似,所以会经常被放到一起。打开 app 时的搜索,刷 app 时的推荐,以及投放用户可能有兴趣的广告。三者的本质几乎是一样的:根据用户喜好,尽可能的在数据库中检索出用户想要的东西,这个东西可能是视频,可能是商品,可能是网页等等。搜广推算法直接和用户点击率挂钩,相当于和收益挂钩,也是互联网中最接近钱的算法。

而为了在计算机中描述用户的喜好,需要对用户的特征进行建模,诸如性别、地区、城市、年龄、上网时间、消费金额等等等等。这些特征都有对应的数值,那么对数值进行清洗、处理后便可以输入 XGBoost 等传统算法完成分类或者是回归等功能。

而早期的 AI 时代,由于神经网络的线性层只接受固定尺寸的输入,所以会将性别、年龄这些特征进行 padding 处理得到一个统一的尺寸。如性别填充为 [1, 0, 0, 0, …],年龄填充为 [0, 18, 0, 0, 0, …],这些输入含有较多的 0,因此可以视为稀疏的信息。这种类似 one-hot 的填充会导致维度太大且过于稀疏。而在神经网络的前向计算中,0 乘以任何数都是 0,所以稀疏输入含有过多的 0 会让模型的参数无效。

为了避免稀疏导致的问题,为每个稀疏的 id 生成一个稠密的 Embedding,将 Embedding 输入 AI 模型进行分类或是预测,最终利用 AI 强大的学习能力来完成搜广推模型的训练。如常见的 deepctr, deepfm 和 DIN 模型等等。id → Embedding 这个映射可以用 torch.nn.Embedding 来实现,本质就是按行查表,写一个简单的模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Model():
def __init__(self):
self.user_emb = nn.Embedding(num_Embeddings=user_fea_num, Embedding_dim=128)
self.item_emb = nn.Embedding(num_Embeddings=item_fea_num, Embedding_dim=128)

self.mlp = nn.Sequential(
nn.Linear(128 * 2, 128),
nn.ReLU(),
nn.Linear(128, 1)
)

def forward(self, user_ids, item_ids):
u = self.user_emb(user_ids) # [batch, 128]
v = self.item_emb(item_ids) # [batch, 128]
x = torch.cat([u, v], dim=-1) # [batch, 2 * 128]
out = self.mlp(x) # [batch, 1]

user_emb、item_emb 都是 nn.Embedding,本质上是两个大矩阵(num_Embeddings, Embedding_dim),而 id → Embedding 的本质就是在一个大矩阵里按行索引。在前向计算阶段,如果 user_ids 是 23,那么只取 user_emb 表的第 23 行得到 [1, 128] 大小的 Embedding 进行计算;在反向阶段,也只更新第 23 行,其余内容则不更新。举一个更详细的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
import torch.nn as nn
import torch.optim as optim

emb = nn.Embedding(num_Embeddings=10, Embedding_dim=4)
optimizer = optim.SGD(emb.parameters(), lr=0.1)

# 这一步只用到了 id: 2, 5, 7
ids = torch.tensor([2, 5, 7], dtype=torch.long)

# 前向:取出对应 Embedding,随便构造个 loss
vecs = emb(ids) # shape [3, 4]
loss = (vecs ** 2).sum() # 只是示例:L = sum(e_i^2)

optimizer.zero_grad()
loss.backward()

print(emb.weight.grad) # 看梯度
optimizer.step()

在海量数据的时代,用户的特征会越来越多,如用户/物料/广告/商品/词组/组合特征等等,甚至还会根据时序特征动态更新模型,如最近 100 次点击/曝光里的 Item ID 序列。这会产生大量的 ID。假设 ID 为 10 亿规模,Embedding 是 64 维的 float32 数据,大约是 256 GB 的数据,加上 dense 部分的模型参数,无法装到一张 GPU 中。传统 ML 在小数据、小特征下已经够用,但在 TB 级曝光日志、亿级 ID 空间、每天要迭代模型参数的环境里,就必须走分布式训练这条路,用多卡的能力来解决显存的问题。

此时需要把 Embedding 进行切片(shard),分片装到不同的 GPU 中。假设切片长度为 65536,共 32 张卡,那么每张卡持有切片的长度就是 65536 / 32 = 2048。第一张卡持有 Embedding 表的 id 范围是 0 到 2048,第二张卡持有 Embedding 表的 id 范围是 2048 到 4096,以此类推。假设输入的 id 是 1180210,1180210 % 65536 = 562,562 位于第一张卡的 id 范围。

如果使用 torch.nn.Embedding 作为 id → Embedding 的实现,那么从第一张卡的 Embedding 表中取出 id 为 562 的 Embedding。可是 721458 % 65535 也等于 562,难道 721458 和 1180210 不同的 id 要共用同一个 Embedding 吗?这显然是不行的。

额外的,虽然 torch.nn.Embedding 查表速度快,但有内存浪费的现象。假如本次训练只有 5000 个 id,但 id 的取值范围是 0 到 1 亿。这会创建 1 亿的表,但只用其中的 5000 行,绝大部分存储空间并没有被使用。综上,简单的 torch.nn.Embedding 不能满足海量数据的场景。我们面临的是:

  • ID 空间规模可达 10^9 级以上,Embedding 表动辄数百 GB;
  • 单机 GPU 无法容纳全部参数;
  • 模型需要在数小时级别完成 TB 级日志训练,并且迭代上线。

此时的问题变成:如何在分布式环境下,如何用更少的内存,以更快的速度,让用户完成一次 TB 级的数据全量训练。前面提到的 Embedding 也只是训练的一个难点。

事实上,一个完整的搜广推实现有召回、粗排、精排等多个步骤:

  • 召回:使用倒排索引检索出 query 相关的一些物品;或者将 query 经过 AI 得到 embedding,使用向量检索技术检索出和 embedding 相关的物品
  • 排序:使用模型对召回结果进行打分

这需要一个强大的基建去完成数据存储(在线、离线、分布式读取)、特征工程、模型训练量化推理、向量检索、实时计算、策略容错和监控运维等多个步骤,每个步骤都有很多坑要踩,有很多技术瓶颈需要突破。果然算法是大公司用来锦上添花的东西,离开大厂的流水线算法寸步难行。业界普遍会在通用 DL 框架 + 推荐/广告特化组件这条路径上演进,而阿里的 RecIS 框架也是路线中的一员,更贴合阿里内部的搜广推场景。

本系列也将阅读 RecIS 的源码,探索 RecIS 如何解决的这些难题,并详细分析实现原理。更多的从框架设计角度持续介绍模型训练这个环节的工程化落地,性能分析就略过了。为什么选择这个框架呢?因为我是这里的员工。免责声明:刚入行半年,如果哪里写的不好可能是本人理解不到位,和 RecIS 无关,标题上带阿里二字也有引流成分。

我个人是看好搜广推算法的,毕竟把锅做大才能吃到更多饭。最近也有一些基于大模型的搜广推算法,如快手的 onerec;在稀疏领域,deepseek 在 26 年发表了 Engram 大模型,这些论文也会以番外篇的形式加入到这个系列。想不到毕业多年我又要开始看论文了哈哈哈。

感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章