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 里那几个矩阵的形状。

emb = {f: EmbeddingTable(rows=桶数[f], dim=16) for f in 域列表}   # 每个域一张表,随机初始化
w1  = {f: EmbeddingTable(rows=桶数[f], dim=1)  for f in 域列表}   # 一阶权重表

def model(sample):
    v = [emb[f].lookup(hash(sample[f]) % 桶数[f]) for f in 域列表]   # 每个域取出一个 16 维向量
    first = sum(w1[f].lookup(hash(sample[f]) % 桶数[f]) for f in 域列表)
    fm    = 0.5 * (sum(v)**2 - sum(x**2 for x in v)).sum()         # 等价于所有两两内积之和
    deep  = mlp(concat(v))                                          # 3 到 4 个矩阵
    return sigmoid(first + fm + deep)

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. 每一批样本循环做四步

  1. 前向计算。 用当前参数算出预估点击率 p
  2. 算损失。 用对数损失衡量预测错得多离谱:loss = -[y·log(p) + (1-y)·log(1-p)]
  3. 反向传播。 用链式法则从输出往回推,得到每个参数的梯度,也就是“把它调大一点,损失变大还是变小、变多少”。
  4. 更新参数。 每个参数沿着让损失变小的方向走一小步: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_idtitle_termsctr_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 每个键三个张量:indicesvaluesdense_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_7dmatch_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 cat_id(DT_INT64, {N, 1});          // 启动时从签名得知类型和列数,请求时只有 N 是变的
Tensor ctr_7d(DT_FLOAT, {N, 1});
auto cat = cat_id.data<int64_t>();        // 一段连续内存
auto ctr = ctr_7d.data<float>();
for (int i = 0; i < N; ++i) {
  cat[i] = features[i].cat_id;            // 第 i 行 = 候选 i
  ctr[i] = features[i].ctr_7d;
}
// 多值特征:边遍历边往 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 部分

代价是模型导出要按约定拆图,并且服务侧的哈希和多值特征的聚合方式必须和训练侧完全一致,否则线上线下会不一致。

怎么选:表能放进单机内存、模型按天更新时,放图内最省事;表很大或者需要分钟级更新时,拿到图外。