DeepFM 端到端:从训练到在线推理
DeepFM 端到端:从训练到在线推理
这篇文档用一个例子讲完 DeepFM 的全过程:训练怎么得到 embedding 表和稠密网络,在线服务怎么把特征变成张量,TensorFlow 怎么拿张量算出点击率。面向没有深度学习背景的工程师。
贯穿全文的例子:用户 u123 搜索“跑鞋”,候选广告是 9001,它属于类目 12、价格档 5。
全文讲的是通用原理,不依赖任何具体系统的实现。推理框架以 TensorFlow 为例,其他框架的步骤相同,只是接口名字不一样。
flowchart LR
L[曝光与点击日志] --> S[训练样本<br/>特征 + 点没点]
S --> T[训练<br/>embedding 表 + 矩阵一起学]
T --> M[导出 SavedModel<br/>图 + 变量 + 签名]
M --> LD[在线服务加载模型]
R[排序请求] --> FG[FG 生成特征]
FG --> TB[组装张量]
LD --> INF[TensorFlow 推理]
TB --> INF
INF --> C[校准并回包]
上半段是离线训练,下半段是在线打分;两者通过 SavedModel 这个文件衔接。
一、DeepFM 的结构
DeepFM 把每个特征域的 ID 变成一个向量,然后分三路计算,再把三路结果加起来:点击率 = sigmoid(一阶项 + FM 项 + Deep 项)。用户 ID、搜索词、广告 ID、类目、价格档,各算一个域。
flowchart TD
IN[各域的 ID<br/>用户 / 搜索词 / 广告 / 类目 / 价格档] --> E[查 embedding 表<br/>每个域得到一个 16 维向量]
IN --> W[查一阶权重表<br/>每个 ID 一个标量]
W --> F1[一阶项<br/>标量相加]
E --> F2[FM 二阶项<br/>两两向量内积之和]
E --> D[Deep 部分<br/>拼接后过多层网络]
F1 --> SUM[三项相加]
F2 --> SUM
D --> SUM
SUM --> OUT[sigmoid<br/>预估点击率]
| 支路 | 算什么 | 作用 |
|---|---|---|
| 一阶项 | 每个特征 ID 对应一个标量权重,全部相加 | 相当于逻辑回归,记住“这个广告本身点击率高不高” |
| FM 二阶项 | 任意两个域的向量做内积,全部相加 | 自动做两两特征交叉,比如“用户 × 广告”“搜索词 × 类目” |
| Deep 部分 | 把所有域的向量首尾拼接,送进多层网络 | 学习更复杂的多因素组合 |
两个要点:
- FM 项和 Deep 部分共用同一套 embedding 向量,不是各查各的。
- 一阶项的权重表可以看成一张维度为 1 的 embedding 表。
每个域的向量一般多大
DeepFM 里所有域用同一个维度,工业界常用 8 到 64,最常见的是 16 或 32。原论文的实验用的是 10(凭记忆)。
- 为什么所有域必须同维。 FM 项要对任意两个域的向量做内积,内积要求两个向量一样长。
- Deep 部分的输入宽度由它决定。 40 个域 × 16 维 = 640,这就是第一个矩阵的列数。
- 内存随维度线性增长。 一张表的大小是“行数 × 维度 × 4 字节”,1 亿行 × 16 维约 6.4 GB。
- 怎么选。 从 8 或 16 起步,逐步加大,看验证集 AUC;通常到 32 或 64 之后提升就很小了,再大容易在样本少的 ID 上过拟合。
没有 FM 这个约束的模型(纯多层网络、DCN 等)可以每个域用不同维度:性别这种只有几个取值的域用 2 到 4 维,商品 ID 这种上千万取值的域用 32 到 64 维。如果既想用不同维度又想做内积,就先用一个小矩阵把各域投影到同一维度。
用数字走一遍
下面用一个能手算的小例子把三条支路各算一遍,最后得到点击率 7.9%。为了能手算,只用 3 个域、每个向量 2 维、Deep 部分只有 1 层 2 个神经元;真实模型是几十到上百个域、每个向量 16 维左右、Deep 部分 3 到 4 层,算法完全一样。所有数字都是为了讲解编的,不是真实模型的参数。
场景: 用户 u123 搜“跑鞋”,候选广告 9001。三个域分别是用户、搜索词、广告。
第 0 步:模型里存了什么
训练好的模型里,每个 ID 存了两样东西:一个标量权重,和一个向量。
| ID | 标量权重 | 向量(2 维) |
|---|---|---|
| 用户 u123 | +0.2 | [0.9, 0.1] |
| 搜索词“跑鞋” | +0.1 | [0.8, 0.3] |
| 广告 9001 | +0.5 | [1.0, 0.2] |
另外还有一个全局的基础分 −6.0,和 Deep 部分的矩阵。
第 1 步:一阶项,就是一张打分卡
一阶项 = 0.2 + 0.1 + 0.5 = 0.8
标量权重的含义是“这个 ID 单独给点击加多少分”:
- 广告 9001 的 +0.5:这个广告不管给谁看,都比平均水平更容易被点。
- 用户 u123 的 +0.2:这个人比一般人更爱点广告。
- 搜索词的 +0.1:搜这个词的人略微更爱点广告。
- 如果某个广告很差,它的权重会是负数,比如 −0.7,相当于扣分。
为什么能直接相加:它们的单位是同一个,都是“分数”。这和银行的信用评分卡是一个道理:有房 +30 分,有逾期 −50 分,各项相加得到总分,总分再换算成违约概率。这里的总分用 sigmoid 函数换算成点击概率:
| 总分 | 换算成的点击率 |
|---|---|
| −6.0 | 0.25% |
| −5.2 | 0.55% |
| −2.46 | 7.9% |
| 0 | 50% |
所以基础分 −6.0 的意思是“什么都不知道时,点击率大约 0.25%”;只看一阶项,总分是 −6.0 + 0.8 = −5.2,点击率 0.55%。
这些权重是怎么学出来的:一开始全是 0。每来一条样本,用 权重 = 权重 − 学习率 × (预估点击率 − 实际结果) 调整这条样本里出现的那几个 ID。
- 广告 9001 被展示、预估 5%、用户点了(实际结果 = 1):
0.05 − 1 = −0.95,权重上调一大步。 - 被展示、预估 5%、没点(实际结果 = 0):
0.05 − 0 = 0.05,权重下调一小步。 - 反复很多次之后,权重停在“预估点击率和这个广告的真实点击率对得上”的位置。
第 2 步:FM 项,算两两之间配不配
一阶项有个缺陷:广告 9001 的 +0.5 对所有人都一样,它不知道“这个广告对这个人合不合适”。FM 项补的就是这个:用两个向量的内积(对应位置相乘再相加)表示两者配不配。
为了直观,可以先假装第 1 维代表“运动属性”、第 2 维代表“价位偏高”。真实模型里每一维没有这样清晰的含义。
用户 · 搜索词 = 0.9×0.8 + 0.1×0.3 = 0.75
用户 · 广告 = 0.9×1.0 + 0.1×0.2 = 0.92
搜索词 · 广告 = 0.8×1.0 + 0.3×0.2 = 0.86
FM 项 = 0.75 + 0.92 + 0.86 = 2.53
三个向量的第 1 维都很大,也就是“爱运动的人、搜运动的词、看运动的广告”,所以两两内积都大,加了 2.53 分。
实际计算时不会真的两两去算,而是用一个等价公式:先把所有向量加起来再逐位平方,减去每个向量先逐位平方再加起来,最后乘 0.5。
向量之和 = [0.9+0.8+1.0, 0.1+0.3+0.2] = [2.7, 0.6] → 平方后 [7.29, 0.36]
各自平方再求和 = [0.81+0.64+1.0, 0.01+0.09+0.04] = [2.45, 0.14]
两者之差 = [4.84, 0.22],加起来 5.06,乘 0.5 = 2.53 // 和上面一致
40 个域时两两组合有 780 对,用这个公式只需要扫一遍 40 个向量。
第 3 步:Deep 项,算更复杂的组合
FM 项只能看两两关系。“用户、搜索词、广告三者同时都偏运动”这种三个以上因素的组合,交给 Deep 部分。先把三个向量首尾拼接成 6 个数:
x = [0.9, 0.1, 0.8, 0.3, 1.0, 0.2]
一个神经元做的事是:给每个输入乘一个系数、加起来、再加一个偏置,结果是负数就输出 0(这一步叫 relu)。两个神经元的系数各是矩阵的一行:
神经元 1:系数 [0.5, 0, 0.5, 0, 0.5, 0],偏置 −1.0
0.5×0.9 + 0.5×0.8 + 0.5×1.0 − 1.0 = 0.35 → 输出 0.35
神经元 2:系数 [0, 1, 0, −1, 0, 1],偏置 0
0.1 − 0.3 + 0.2 = 0 → 输出 0
Deep 项 = 0.6×0.35 + 0.4×0 = 0.21 // 输出层系数 [0.6, 0.4]
神经元 1 只看三个向量的第 1 维,而且偏置是 −1.0,所以只有三者的“运动属性”加起来足够大时它才输出正数。它学到的就是“三者都偏运动”这个组合。这些系数同样是训练出来的,不是人填的。
第 4 步:加起来,换算成概率
总分 = 基础分 + 一阶项 + FM 项 + Deep 项
= −6.0 + 0.8 + 2.53 + 0.21 = −2.46
点击率 = sigmoid(−2.46) = 7.9%
对比:换一个不匹配的用户
同一个广告、同一个搜索词,换成一个平时只看美妆的用户:标量权重 0,向量 [−0.7, 0.4]。
| 项 | 用户 u123(爱运动) | 美妆用户 |
|---|---|---|
| 一阶项 | 0.8 | 0.6 |
| 用户 · 搜索词 | 0.75 | −0.44 |
| 用户 · 广告 | 0.92 | −0.62 |
| 搜索词 · 广告 | 0.86 | 0.86 |
| FM 项 | 2.53 | −0.20 |
| Deep 项 | 0.21 | 0.12 |
| 总分 | −2.46 | −5.48 |
| 点击率 | 7.9% | 0.42% |
一阶项几乎没变,因为广告和搜索词本身的吸引力没变。差距全部来自 FM 项:用户向量和广告向量方向相反,内积是负数,直接扣分。同一个广告,对两个人的预估点击率差了近 20 倍,这就是个性化排序的来源。
真实模型有多少个域
实际会用到几十到几百个域。公开的 Criteo 广告数据集是 39 个域(13 个数值型加 26 个类别型),工业系统通常在 50 到 300 个之间。大致构成:
| 类别 | 例子 | 大致数量 |
|---|---|---|
| 用户画像 | 用户 ID、性别、年龄段、城市、消费档次、偏好类目 | 10~30 |
| 用户行为 | 最近点过的商品、店铺、类目,各时间窗的点击和购买次数 | 10~50 |
| 物料 | 广告 ID、商品 ID、店铺、类目、品牌、价格档、各时间窗的点击率分桶 | 20~80 |
| 上下文 | 搜索词、时段、广告位、机型、网络 | 5~20 |
| 人工交叉 | 搜索词 × 类目、用户偏好类目 × 商品类目 | 10~100 |
域的数量只影响“查多少次表、拼出来的向量多长”,上面四步的算法不变。
二、训练:embedding 表和稠密网络是怎么得到的
embedding 表和稠密网络的矩阵是在同一个训练过程里一起学出来的:先随机初始化,再用海量“特征 → 点没点”的样本反复试错。网络的结构是人写的,训练只负责填数字。
1. 网络结构由人写
算法工程师在代码里定好三件事:有哪些域、每个域的 embedding 是多少维、Deep 部分有几层以及每层多宽。所谓“生成稠密网络”,就是声明 mlp 里那几个矩阵的形状。
= # 每个域一张表,随机初始化
= # 一阶权重表
= # 每个域取出一个 16 维向量
=
= 0.5 * # 等价于所有两两内积之和
= # 3 到 4 个矩阵
return
2. 样本来自日志
每一次曝光产生一条样本,标签是点了记 1、没点记 0:
{user_id: u123, query_terms: [跑, 鞋], ad_id: 9001, cat_id: 12, price_bucket: 5, …, label: 1}
3. ID 先变成行号
- 做法是
hash(特征值) % 这张表的行数。不同 ID 可能撞到同一行,这种碰撞是接受的。 - 用户、搜索词、广告各查各的表。
- 搜索词有多个词,每个词取一个向量,再求和或取平均,合成一个向量。
例子:三条日志从 ID 到一次训练更新
这个例子把三条日志变成行号,再拿样本 1 完整训练一步:预估点击率是 6.5%,用户实际点了,更新之后同一条样本的预估变成 18.3%。
为了能手算,做了四个简化:
- 这里说的“表”都是 embedding 表:每个域一张,每一行存一个向量。为了演示把它们设得很小:用户 embedding 表 8 行,搜索词 embedding 表 8 行,广告 embedding 表 4 行。真实的 embedding 表有几百万到几十亿行。下文简称用户表、搜索词表、广告表。
- 向量只有 2 维,并且先不算 Deep 项。
- 学习率取 0.1,是为了让变化看得见;真实训练的学习率小得多。
- 表里的初始数字是编的。哈希值是用 CRC32 真实算出来的,可以自己验证。
第 1 步:原始日志。
样本 1:用户 u123,搜索词 [跑, 鞋], 广告 9001,点了
样本 2:用户 u456,搜索词 [口红], 广告 9003,没点
样本 3:用户 u789,搜索词 [篮球, 鞋],广告 9002,没点
第 2 步:逐个 ID 算行号。 规则是 行号 = CRC32(ID) % 表的行数。
| 域 | ID | CRC32 | 表的行数 | 行号 |
|---|---|---|---|---|
| 用户 | u123 | 179058034 | 8 | 2 |
| 用户 | u456 | 860657105 | 8 | 1 |
| 用户 | u789 | 337332308 | 8 | 4 |
| 搜索词 | 跑 | 1950139303 | 8 | 7 |
| 搜索词 | 鞋 | 3436891905 | 8 | 1 |
| 搜索词 | 口红 | 293402857 | 8 | 1 |
| 搜索词 | 篮球 | 1276998221 | 8 | 5 |
| 广告 | 9001 | 110410606 | 4 | 2 |
| 广告 | 9002 | 2677926612 | 4 | 0 |
| 广告 | 9003 | 3902462530 | 4 | 2 |
这里发生了两处碰撞:“鞋”和“口红”都落在搜索词表的第 1 行,广告 9001 和 9003 都落在广告表的第 2 行。撞在一起的 ID 共用同一行。另外,用户表的第 2 行和广告表的第 2 行是两张不同的表里的行,互不相干。
第 3 步:样本变成纯数字。 送进模型的就是这些行号和标签:
样本 1:用户行 2,搜索词行 [7, 1],广告行 2,标签 1
样本 2:用户行 1,搜索词行 [1], 广告行 2,标签 0
样本 3:用户行 4,搜索词行 [5, 1],广告行 0,标签 0
第 4 步:表里现在存着什么。 每个域有两张表,行数相同、用同一个行号:一张是 embedding 表,每行存一个向量;另一张是一阶权重表,每行只存一个数。只列出样本 1 和样本 2 会碰到的行:
| 表 | 行号 | 落在这一行的 ID | 向量 | 一阶权重 |
|---|---|---|---|---|
| 用户表(8 行) | 2 | u123 | [0.9, 0.1] | 0.20 |
| 用户表 | 1 | u456 | [−0.7, 0.4] | 0.00 |
| 搜索词表(8 行) | 7 | 跑 | [0.7, 0.2] | 0.05 |
| 搜索词表 | 1 | 鞋、口红(碰撞) | [0.9, 0.4] | 0.15 |
| 广告表(4 行) | 2 | 9001、9003(碰撞) | [1.0, 0.2] | 0.50 |
另有全局基础分 −6.0。
第 5 步:拿样本 1 的行号查表。
用户向量 = 用户表[2] = [0.9, 0.1]
搜索词向量 = (搜索词表[7] + 搜索词表[1]) / 2 = ([0.7,0.2] + [0.9,0.4]) / 2 = [0.8, 0.3]
广告向量 = 广告表[2] = [1.0, 0.2]
用户权重 = 0.20
搜索词权重 = (0.05 + 0.15) / 2 = 0.10
广告权重 = 0.50
搜索词有两个词,所以查到两行,取平均合成一个向量;一阶权重同样取平均。
第 6 步:前向计算。
一阶项 = 0.20 + 0.10 + 0.50 = 0.80
FM 项 = 用户·搜索词 + 用户·广告 + 搜索词·广告 = 0.75 + 0.92 + 0.86 = 2.53
总分 = −6.0 + 0.80 + 2.53 = −2.67
预估 p = sigmoid(−2.67) = 6.5%
第 7 步:和标签比较。 用户实际点了,标签是 1:p − 标签 = 0.065 − 1 = −0.935。负数表示预估低了,接下来所有碰到的参数都往“让总分变高”的方向调。
第 8 步:更新一阶权重。 规则是 权重 = 权重 − 学习率 × (p − 标签) × 该权重在总分里的系数:
| 参数 | 系数 | 更新前 | 更新后 |
|---|---|---|---|
| 基础分 | 1 | −6.000 | −5.906 |
| 用户表[2] 的权重 | 1 | 0.200 | 0.294 |
| 搜索词表[7] 的权重 | 0.5(因为取了平均) | 0.050 | 0.097 |
| 搜索词表[1] 的权重 | 0.5 | 0.150 | 0.197 |
| 广告表[2] 的权重 | 1 | 0.500 | 0.594 |
第 9 步:更新向量。 FM 项里,用户向量和另外两个向量各做了一次内积,所以它的梯度方向就是“另外两个向量之和”:
用户向量的梯度方向 = 搜索词向量 + 广告向量 = [0.8,0.3] + [1.0,0.2] = [1.8, 0.5]
用户表[2] = [0.9, 0.1] − 0.1 × (−0.935) × [1.8, 0.5] = [1.068, 0.147]
广告向量的梯度方向 = 用户向量 + 搜索词向量 = [1.7, 0.4]
广告表[2] = [1.0, 0.2] + 0.0935 × [1.7, 0.4] = [1.159, 0.237]
搜索词向量的梯度方向 = 用户向量 + 广告向量 = [1.9, 0.3],两个词各分一半
搜索词表[7] = [0.7, 0.2] + 0.0935 × 0.5 × [1.9, 0.3] = [0.789, 0.214]
搜索词表[1] = [0.9, 0.4] + 0.0935 × 0.5 × [1.9, 0.3] = [0.989, 0.414]
用大白话说:用户点了,所以用户向量被往“搜索词和广告所在的方向”拉,广告向量被往“用户和搜索词所在的方向”拉,三者互相靠近,下次内积更大。这一步只动了四行向量,表里其余的行都没变。
第 10 步:验证。 用更新后的参数重新算样本 1:一阶项 1.03,FM 项 3.37,总分 −1.50,预估从 6.5% 升到 18.3%。模型朝着“这种情况会点”修正了一步。
第 11 步:碰撞的代价。 样本 2 是“美妆用户 u456 搜口红,看到广告 9003”。它要查的搜索词表[1] 和广告表[2],正好是刚刚被样本 1 改过的两行:
| 样本 2 的预估 | 数值 |
|---|---|
| 样本 1 更新之前 | 0.42% |
| 只算基础分的变化(正常影响) | 0.46% |
| 算上碰撞的那两行 | 0.60% |
“跑鞋广告被点了”这件事,通过共用的行,把“口红”和广告 9003 的预估也抬高了,这是错误的。接下来样本 2 的标签是 0,又会把这两行往回拉一点。碰撞的 ID 就这样互相干扰,谁也学不准;表设得足够大,就是为了让这种情况少发生。
真实的 DeepFM 里,Deep 项也会给这几个向量传回一份梯度,和 FM 项传回的那份相加后一起更新;Deep 部分自己的矩阵也同时更新。步骤和上面完全一样,只是多了一条梯度来源。
另外两种常见做法:
| 做法 | 怎么做 | 特点 |
|---|---|---|
| 所有域共用一张大表 | 把域名拼进键里再哈希,比如 CRC32("user=u123") % 行数 |
只管理一张表;不同域的 ID 也可能撞在一起 |
| 用字典代替哈希 | 训练前扫一遍样本,给每个出现过的 ID 分配连续编号;没见过的 ID 统一映射到一个“未知”行 | 没有碰撞;但字典要随模型一起发布,新 ID 要等下一版字典才有自己的行 |
在线推理时必须用和训练完全相同的哈希函数和表行数。否则同一个 ID 在线上查到的是另一行,等于拿错了向量,模型不会报错,只会静默变差。
4. 每一批样本循环做四步
- 前向计算。 用当前参数算出预估点击率
p。 - 算损失。 用对数损失衡量预测错得多离谱:
loss = -[y·log(p) + (1-y)·log(1-p)]。 - 反向传播。 用链式法则从输出往回推,得到每个参数的梯度,也就是“把它调大一点,损失变大还是变小、变多少”。
- 更新参数。 每个参数沿着让损失变小的方向走一小步:
w = w - 学习率 × 梯度。
embedding 表里只有这一批样本查到的那些行会被更新;Deep 部分的矩阵每一批都会全部更新。
5. 向量是怎么学出含义的
FM 项对用户向量的梯度,等于这条样本里其他域的向量之和,再乘以 (p − y)。用大白话说:
- 用户点了这个广告:用户向量、广告向量、搜索词向量被互相拉近,下次内积变大,预估分变高。
- 用户没点:这几个向量被互相推远。
- 重复几十亿次之后,爱买跑鞋的用户的向量会靠近跑鞋类广告的向量,“跑”“鞋”两个词的向量也会靠近跑鞋广告的向量。
向量的含义是被样本一点点挤出来的,没有人给任何一维指定过意义。
6. 三张表各自学到什么
| 表 | 学到的内容 | 注意 |
|---|---|---|
| 广告 ID | 这个广告的吸引力和类型 | 新广告没有训练过的行,只能靠类目、价格等内容特征兜底 |
| 用户 ID | 这个人的口味 | 低活跃用户样本太少,这一行学不好,所以还会加性别、年龄段、偏好类目等域 |
| 搜索词 | 词在购物语境下的含义 | 按词建行,不按整个搜索串建行,这样没见过的搜索串也能由见过的词组合出来 |
7. 导出成 SavedModel
训练完成后导出的 SavedModel 包含三样东西:计算图、所有变量、输入输出签名。embedding 表和那几个矩阵在这里都只是“变量”,其中 embedding 表占了绝大部分体积。
三、在线:FG 怎么生成张量
在线服务要做的是把“每个候选的一串特征”组装成模型入口要求的那组张量,行是候选,一次请求就是一个 batch。FG 是特征生成库(feature generation)的简称,它和离线拼样本用的是同一套逻辑,保证线上线下特征一致。
1. FG 的输出
对每个候选,FG 按配置把原始数据变成一串特征。每个特征可以看成下面这样一条记录:
| 字段 | 内容 | 例子 |
|---|---|---|
| 属于哪个输入 | 特征名,或者预先分配好的槽位编号 | cat_id、title_terms、ctr_7d |
| 值 | 类别型特征是 ID 或字符串,可以有多个;连续型特征是浮点数 | 12、[跑, 鞋]、0.031 |
FG 做的事情包括取字段、分桶(把连续值切成档位)、取对数、分词、两个特征组合成交叉特征等。
什么时候需要人工交叉特征
FM 项已经能通过向量内积自动学习两两交叉,但人工交叉仍然有用,通常两者一起用:自动交叉负责泛化,人工交叉负责记住高频组合。
人工交叉特征的做法是造一个新的 ID:把两个特征的取值组合起来(拼接后哈希,或者直接组合两边的哈希值),当成一个新域的取值。比如“用户偏好类目=运动”和“广告类目=跑鞋”组合成“运动_跑鞋”。这个新 ID 有自己的一行 embedding 和自己的一阶权重,模型把它当普通特征处理。
| 自动交叉(FM 的向量内积) | 人工交叉(新造一个 ID) | |
|---|---|---|
| 长处 | 泛化:从没一起出现过的组合也能算出分数 | 记忆:每个具体组合有自己的参数,高频组合学得更准、更快 |
| 短处 | 所有组合共用两边的向量,对个别特殊组合不够精确 | 没见过的组合没有参数;表会膨胀 |
| 适合 | 细粒度特征之间,比如用户 ID × 广告 ID | 高频、业务上已知很重要的组合,比如搜索词 × 类目 |
做人工交叉时的三条经验:
- 只交叉粗粒度的特征。 交叉后的取值数量接近两边取值数量的乘积。“类目 × 性别”“类目 × 搜索词”可以做;“用户 ID × 广告 ID”几乎没有重复样本,学不出东西,不要做。
- 控制多值交叉的膨胀。 5 个搜索词配 10 个标题词就是 50 个 ID。常见做法是只保留两边都出现的词,或者先算一个数值(比如搜索词里有多少比例出现在标题里)再分桶,只输出 1 个 ID。
- 连续值先分桶再交叉。 点击率 0.0312 和 0.0313 直接拼会变成两个 ID,各自的样本都太少。
交叉特征同时依赖请求和物料两边,只能在线现算,是 FG 在线计算量的主要来源。它的 embedding 表要单独设行数,并过滤掉出现次数太少的组合。
2. 服务怎么知道模型要哪些输入
模型导出时带着一份输入签名:有哪些输入、各自的名字、类型和形状,哪些是稠密输入、哪些是稀疏输入。服务在加载模型时读取这份签名,建立“特征名 → 第几个输入张量”的映射;这个映射只建一次,请求路径上直接用下标。
3. 按这份清单组装张量
假设本批有 N 个候选:
| 输入类型 | 例子 | 张量形式 |
|---|---|---|
| 稠密输入 | 7 天点击率、归一化价格 | 每个键一个形状为 [N, k] 的张量,第 i 行是第 i 个候选的值 |
| 稀疏输入 | 标题词、类目 ID | 每个键三个张量:indices、values、dense_shape |
稀疏输入要用三个张量,是因为每个候选的值个数不固定。举例:N = 2,候选 0 的标题词是 [跑, 鞋],候选 1 的标题词是 [篮球, 鞋, 男]。
indices = [[0,0],[0,1],[1,0],[1,1],[1,2]] // 每个值属于第几行、是该行的第几个
values = [跑, 鞋, 篮球, 鞋, 男] // 值本身
dense_shape = [2, 3] // 逻辑形状:2 行,最长的一行 3 个值
4. 两种喂法:序列化样本,或者直接喂张量
训练时,图的入口通常是一个解析算子(TensorFlow 里叫 ParseExample),它把序列化的 tf.Example 样本解析成上面那组张量。在线有两种喂法:
| 喂法 | 做法 | 代价 |
|---|---|---|
| 喂序列化样本 | 每个候选拼一个 tf.Example 并序列化,图内再解析 |
和训练完全同一条路径,最简单;但“拼、序列化、解析”三步都是纯开销 |
| 直接喂张量 | 服务自己组装好张量,喂到解析算子的输出端,或者导出模型时就把入口定义成张量 | 省掉上面三步;代价是服务要自己保证张量的形状和顺序与签名一致 |
延迟敏感的服务都用第二种。
5. 分片与合并
候选很多时切成几个分片并行生成特征。更好的做法是预先分配一块大张量,各分片直接写自己的行区间;如果各分片各自组装,最后要拼成一个大 batch,稀疏输入的行号按分片偏移量平移。
6. 完整例子:一次请求从原始数据到张量
这个例子把一次请求从头走到“喂给模型的那组张量”。要点是:FG 的输出仍然是一个个候选各自的特征值,张量只是把所有候选的同一个特征按行排进一个数组。
场景:用户 u123 在晚上 21 点搜“跑鞋”,有 2 个候选广告 9001 和 9002。真实请求是几百个候选,做法一样。
第 1 步:服务先把原始数据取齐。 这一步不是 FG 做的,是服务从请求、用户特征存储和物料表里读出来的。
| 来源 | 原始数据 |
|---|---|
| 请求本身 | 用户 u123,搜索串“跑鞋”,时间 21 点 |
| 用户特征存储 | 性别 男 |
| 物料表,广告 9001 | 类目 12(跑鞋),价格 299 元,标题词 [男, 跑, 鞋, 透气],7 天点击率 0.031 |
| 物料表,广告 9002 | 类目 15(篮球鞋),价格 1299 元,标题词 [篮球, 鞋],7 天点击率 0.008 |
第 2 步:FG 按配置逐个特征加工。 配置里写着模型要哪些特征、每个特征用什么算子、输入是哪个原始字段。
| 特征 | 作用域 | 算子 | 计算过程 |
|---|---|---|---|
user_id |
请求级 | 直接取值 | u123 |
query_terms |
请求级 | 分词 | “跑鞋” → [跑, 鞋] |
hour_bucket |
请求级 | 分桶 | 21 点 → 第 3 档(晚间) |
cat_id |
物料级 | 直接取值 | 12;15 |
price_bucket |
物料级 | 取 log2 再向下取整 | log2(299) = 8.2 → 8;log2(1299) = 10.3 → 10 |
title_terms |
物料级 | 直接取值(多值) | [男, 跑, 鞋, 透气];[篮球, 鞋] |
ctr_7d |
物料级 | 直接取值(连续值) | 0.031;0.008 |
gender_x_cat |
交叉 | 两个取值组合 | 男_12;男_15 |
match_cnt |
交叉 | 搜索词里有几个出现在标题词里 | 9001:跑、鞋都在 → 2;9002:只有鞋 → 1 |
请求级特征整个请求只算一次;物料级特征和请求无关,可以提前算好存在物料表里;只有交叉特征必须每个候选现算。
第 3 步:FG 的输出。 到这一步仍然是“每个候选一份特征值”,还不是张量:
请求级(所有候选共用):user_id = u123,query_terms = [跑, 鞋],hour_bucket = 3
候选 0(广告 9001):cat_id = 12,price_bucket = 8, title_terms = [男, 跑, 鞋, 透气],
ctr_7d = 0.031,gender_x_cat = 男_12,match_cnt = 2
候选 1(广告 9002):cat_id = 15,price_bucket = 10,title_terms = [篮球, 鞋],
ctr_7d = 0.008,gender_x_cat = 男_15,match_cnt = 1
第 4 步:类别型的值变成整数。 字符串不能直接进张量算,每个类别型的值要先哈希成一个 64 位整数。这一步可以由 FG 做,也可以交给图内的哈希算子做;不管谁做,必须和训练时一致。下面用 CRC32 示意:
男 → 331642374 跑 → 1950139303 鞋 → 3436891905 透气 → 983410913 篮球 → 1276998221
连续值(ctr_7d、match_cnt)不用哈希,保持浮点数。哈希值再对表的行数取模得到行号,那是图内查 embedding 表时的事,见第四节。
第 5 步:按列排成张量。 张量就是一个带类型和形状的多维数组,内存里是一段连续的数。做法是:模型签名里的每个输入对应一个张量,第 i 行放候选 i 的值。候选数 N = 2:
| 输入名 | 类型与形状 | 内容 |
|---|---|---|
cat_id |
int64,[2, 1] |
[[12], [15]] |
price_bucket |
int64,[2, 1] |
[[8], [10]] |
ctr_7d |
float,[2, 1] |
[[0.031], [0.008]] |
match_cnt |
float,[2, 1] |
[[2.0], [1.0]] |
gender_x_cat |
int64,[2, 1] |
[[hash(男_12)], [hash(男_15)]] |
user_id |
int64,[2, 1] |
[[hash(u123)], [hash(u123)]] |
hour_bucket |
int64,[2, 1] |
[[3], [3]] |
请求级特征对每个候选都一样,最简单的做法是像上面那样复制 N 行。模型拆成用户侧和物料侧两个子图时,请求级特征只喂一行,形状是 [1, 1]。
多值特征 title_terms 每个候选的值个数不同(4 个和 2 个),放不进一个规整的矩形。有两种表示法:
表示法一:稀疏三元组(只存有值的位置)
indices = [[0,0],[0,1],[0,2],[0,3],[1,0],[1,1]] // 每个值在第几行、第几个
values = [331642374, 1950139303, 3436891905, 983410913, 1276998221, 3436891905]
dense_shape = [2, 4] // 2 行,最长的一行 4 个值
表示法二:补齐成矩形(短的行用 0 填充,0 约定为“无”)
title_terms = [[331642374, 1950139303, 3436891905, 983410913],
[1276998221, 3436891905, 0, 0 ]] // int64,[2, 4]
请求级的多值特征 query_terms 同理,两行都是 [跑, 鞋] 的哈希。
第 6 步:代码上怎么填。 先按候选数分配好每个张量,再逐个候选把值写到自己那一行:
Tensor ; // 启动时从签名得知类型和列数,请求时只有 N 是变的
Tensor ;
auto cat = cat_id.; // 一段连续内存
auto ctr = ctr_7d.;
for
// 多值特征:边遍历边往 indices / values 两个数组后面追加,最后记下最长的一行作为 dense_shape
最后把“输入名 → 张量”这组对应关系交给推理框架,就进入第四节的推理过程。
整个过程可以概括成一句话:FG 的输出是“按候选组织的特征值”(一个候选一份),张量是“按特征组织的数组”(一个特征一个数组,每行一个候选)。组装张量就是把前者转置成后者。
四、TensorFlow 怎么拿着张量去推理
推理分三步:启动时加载模型、预编译一次调用、每个请求跑一次图。图内部的计算顺序和第一节的结构图一一对应。
1. 加载
LoadSavedModel 把图和变量读进内存,得到一个 session。embedding 表此时就是 session 里的几个大变量。
2. 预编译调用
MakeCallable 只调用一次,声明“我喂哪些张量、要取哪个输出”。TF 据此裁掉用不到的子图,并排好各算子的执行计划。如果喂的是解析算子的输出端,解析算子本身也会被裁掉。
3. 每个请求调一次 RunCallable
图内部依次发生以下几步:
| 步骤 | 做什么 | 输出 |
|---|---|---|
| 取值变行号 | 每个稀疏输入的字符串或整数值,经图内的哈希算子变成行号。和训练时是同一个算子,所以线上线下行号一致 | 每个值一个行号 |
| 查表 | 按行号从 embedding 变量里取行;一个候选有多个值时,按 indices 分组求和或取平均 |
每个域一个 [N, 16] 的张量 |
| 一阶项 | 查维度为 1 的权重表,再相加 | [N] |
| FM 项 | 0.5 ×(向量和的平方 − 向量平方的和) |
[N] |
| Deep 部分 | 各域向量拼接,依次做几次“矩阵乘法 + relu” | [N] |
| 合并输出 | 三项相加,过 sigmoid | 形状为 [N] 的预估分 |
FM 项的那个公式一次算出所有两两内积之和。计算量和域的个数成正比,而不是和域数的平方成正比。
4. 线程
- 互不依赖的算子,由 inter-op 线程池并行执行。
- 单个大的矩阵乘法内部,由 intra-op 线程池并行。
- 这两个线程池的大小要显式设置。默认值各约等于 CPU 核数,再加上服务自己的业务线程,总线程数会远超核数,造成延迟长尾。
5. 回到业务代码
输出张量按分片拆回去;每个分数经过校准(系数、幂次、负采样纠偏)后写进响应。
五、embedding 放在图内还是图外
上面讲的是最直接的部署方式:embedding 表装在 SavedModel 里面,查表发生在 TF 图内。它简单,但有两个后果:每次模型更新都要整包重载,做不到分钟级的增量更新;表的大小受单机内存限制。
另一种方式是把 embedding 查表从图里拿出来,改的是第四节第 3 步里的前两个环节:
| 环节 | 现在(图内) | 拿出图外之后 |
|---|---|---|
| 取值变行号 | TF 图内的哈希算子 | 服务自己算,和训练侧约定同一个哈希函数 |
| 查表 | 从 session 的变量里取行 | 服务查自己的 embedding 存储,可以做量化、冷热分层和流式增量 |
| 喂给 TF 的输入 | 稀疏输入的三元组和稠密输入 | 形状为 [N, 域数, 16] 的稠密张量,加上一阶权重 |
| TF 图里剩下的 | 全部 | 一阶项求和、FM 项、Deep 部分 |
代价是模型导出要按约定拆图,并且服务侧的哈希和多值特征的聚合方式必须和训练侧完全一致,否则线上线下会不一致。
怎么选:表能放进单机内存、模型按天更新时,放图内最省事;表很大或者需要分钟级更新时,拿到图外。
暂无评论,欢迎留下第一条评论。