LR 模型:从打分卡到在线推理

这篇文档用一个例子讲完 LR(逻辑回归)的全过程:模型里存的是什么,分数怎么算、怎么变成点击率,分值是怎么从日志里训练出来的,在线服务怎么把一次请求变成打分。面向没有机器学习背景的工程师,所有数字都能用计算器复算。

贯穿全文的例子和 DeepFM 那篇相同:用户 u123 搜索“跑鞋”,候选广告是 9001(类目 12、价格档 8)和 9002(类目 15、价格档 10)。文中的权重数字都是为了讲解编的,不是真实模型的参数。

LR 是广告点击率预估最早大规模使用的模型,也是 DeepFM 一阶项的原型。理解了它,再看 FM、DeepFM 就只是往上加东西。

flowchart LR
  L[曝光与点击日志] --> S[训练样本<br/>特征 + 点没点]
  S --> T[训练<br/>一张“特征 → 分值”表]
  T --> M[导出权重表]
  M --> LD[在线服务加载]
  R[排序请求] --> FG[FG 生成特征]
  FG --> H[特征哈希成行号]
  LD --> SC[查表、相加、换算概率]
  H --> SC
  SC --> C[校准并回包]

上半段是离线训练,下半段是在线打分。两者共用同一套“特征名 → 行号”的哈希规则,这是线上线下一致的关键。

一、LR 是什么:一张打分卡

LR 只做两件事:把出现的特征的分值加起来得到总分,再把总分换算成概率。模型本身就是一张“特征 → 分值”的表,外加一个基础分。

1. 模型里存了什么

特征 分值(权重) 含义
基础分 −6.0 什么都不知道时的起始分,对应点击率约 0.25%
user=u123 +0.2 这个人比一般人更爱点广告
query=跑鞋 +0.1 搜这个词的人略微更爱点广告
ad=9001 +0.5 这个广告比平均水平更吸引人
ad=9002 −0.7 这个广告比较差,扣分
cat=12 +0.3 跑鞋类目的广告整体点击率偏高
cat=15 −0.2 篮球鞋类目偏低
price=8 0.0 这个价格档不加不减
price=10 −0.1 贵一档,略扣

真实模型的这张表有几千万到几十亿行,每个出现过的特征取值一行。表里只有数字,没有任何“含义”列,含义是人看着数字解读出来的。

2. 第一步:算总分

把这次请求里出现的特征的分值加起来。对候选 9001:

总分 = 基础分 + user=u123 + query=跑鞋 + ad=9001 + cat=12 + price=8
     = −6.0   + 0.2       + 0.1       + 0.5     + 0.3    + 0.0     = −4.9

对候选 9002:

总分 = −6.0 + 0.2 + 0.1 + (−0.7) + (−0.2) + (−0.1) = −6.7

没出现的特征(比如 user=u456)不参与,等于加了 0。

3. 第二步:总分换算成概率

换算用 sigmoid 函数:概率 = 1 / (1 + e^(−总分))。它把任意大小的分数压到 0 和 1 之间,分数越高概率越大,但永远不会超过 1 或低于 0。

总分 概率
−6.7(候选 9002) 0.12%
−6.0(只有基础分) 0.25%
−4.9(候选 9001) 0.74%
−3.0 4.74%
0 50%
+3.0 95.26%

所以 9001 的预估点击率是 0.74%,9002 是 0.12%,排序时 9001 排前面。

写成公式就是教科书上的样子:p = sigmoid(w·x + b)b 是基础分,w 是所有分值排成的长向量,x 是下一节要讲的 0/1 向量。

4. 为什么分值能直接相加

因为分值是“赔率的对数”:分值相加,等价于赔率相乘。

赔率 = 点的概率 ÷ 不点的概率。点击率 0.25% 时,赔率约为 1 : 403,也就是平均 404 次展示里点 1 次。sigmoid 的定义反过来写就是 总分 = ln(赔率),或者说 赔率 = e^总分

因素 分值 赔率变成原来的几倍(e^分值)
基础分 −6.0 起始赔率 1 : 403
user=u123 +0.2 × 1.22
query=跑鞋 +0.1 × 1.11
ad=9001 +0.5 × 1.65
cat=12 +0.3 × 1.35
最终赔率 = (1 : 403) × 1.22 × 1.11 × 1.65 × 1.35 = 1 : 134
点击率   = 1 / (1 + 134) = 0.74%                                  // 和第 3 步算出来的一样

所以广告 9001 的 +0.5 真正的意思是:不管其他条件如何,这个广告把点击的赔率放大 1.65 倍。分值是负数就是缩小,−0.7 对应乘以 0.50。

直接对概率做加法是不行的:概率只能在 0 到 1 之间,相加会超过 1 或低于 0;分值可以是任意实数,随便加。先在分值上做加法,最后用 sigmoid 统一换算回概率,结果永远合法。

5. 特征怎么变成 0/1 向量

模型只认数字。把“user=u123”这种特征变成数字的标准做法叫 one-hot:给每个可能的取值留一个位置,出现的那个位置填 1,其余全填 0。假设全系统只有 2 个用户、2 个搜索词、2 个广告:

位置 user=u123 user=u456 query=跑鞋 query=口红 ad=9001 ad=9002
分值 w 0.2 0.0 0.1 0.3 0.5 −0.7
本次请求的 x 1 0 1 0 1 0
教科书写法:w·x = 0.2×1 + 0.0×0 + 0.1×1 + 0.3×0 + 0.5×1 + (−0.7)×0 = 0.8
查表写法:  w[u123] + w[跑鞋] + w[9001]              = 0.2 + 0.1 + 0.5     = 0.8

乘以 0 的项全部消失,乘以 1 的项就是分值本身。所以“向量相乘”退化成了“把出现的特征的分值查出来相加”。真实系统里 x 有几十亿个位置,一次请求只有几十到几百个位置是 1,没有人会真的构造这个向量,工程上从来都是查表再相加。

6. 真实模型有多少特征

类别 例子 取值数量级
用户 ID 类 user=u123 千万到亿
用户属性 gender=男、age=25-30、city=杭州 几十到几千
搜索词 query=跑鞋、term=跑、term=鞋 百万
广告 / 商品 ad=9001、goods=…、mall=… 百万到亿
类目 / 价格档 / 位置 cat=12、price=8、pos=3 几十到几万
人工交叉 query=跑鞋_cat=12、gender=男_cat=12 亿级

表的行数是所有类别取值数量之和,所以 LR 模型的大小主要由 ID 类和交叉特征决定。每一行只有一个 float,1 亿行也就 400 MB。

二、训练:分值是怎么学出来的

训练就是拿历史日志反复试错:预测低了就把相关特征的分值调高,预测高了就调低,直到预估点击率和真实点击率对得上。整个过程只有一条更新规则。

1. 样本

每一次广告曝光是一条样本:当时出现的特征,加上结果(点了记 1,没点记 0)。

样本 1:用户 u123,搜索词 跑鞋,  广告 9001,类目 12,点了
样本 2:用户 u456,搜索词 口红,  广告 9003,类目 40,没点
样本 3:用户 u789,搜索词 篮球鞋,广告 9002,类目 12,没点

2. 特征先变成行号

表是数组,不是字典,所以要先把特征字符串变成行号。规则是 行号 = CRC32("特征名=取值") % 表的行数。这个例子用 64 行的表(真实模型是几亿行),哈希值是用 CRC32 真实算出来的:

特征 CRC32 行号(% 64)
user=u123 2480889238 22
user=u456 2856320309 53
user=u789 2372411568 48
query=跑鞋 3068674610 50
query=口红 2025965121 1
query=篮球鞋 3924393614 14
ad=9001 1571409190 38
ad=9002 3298860188 28
ad=9003 3014110218 10
cat=12 1570018704 16
cat=40 3471638777 57

三条样本变成纯数字:

样本 1:行 [22, 50, 38, 16],标签 1
样本 2:行 [53, 1, 10, 57],  标签 0
样本 3:行 [48, 14, 28, 16], 标签 0

“cat=12”在样本 1 和样本 3 里都出现,都落在第 16 行,这一行的分值会被两条样本共同影响。这正是模型能“举一反三”的原因:跑鞋类目的分值是从所有跑鞋类目的样本里学来的,不是只从某一个广告学来的。

3. 更新规则

对每一条样本做三步:

  1. 用当前分值算预估点击率 p
  2. 算误差 p − 标签。点了,误差是负数;没点,误差是正数。
  3. 这条样本里出现的每个特征(包括基础分):分值 = 分值 − 学习率 × 误差

点了而预测低,误差是接近 −1 的负数,分值被调高;没点而预测不算低,误差是正数,分值被调低。没出现的特征分值不动。学习率控制每一步走多大,是人设定的。

这条规则不是拍脑袋定的。衡量预测好坏用的是对数损失 loss = −[y·ln(p) + (1−y)·ln(1−p)],对它求导,得到的梯度恰好就是 (p − y) × xx 在特征出现时是 1,没出现时是 0,所以只有出现的特征会更新。

4. 三条样本走一遍

学习率取 0.5(为了几步就能看出变化;真实训练的学习率小得多),基础分从 −3.0 开始,其他分值全部从 0 开始。

样本 1(u123,跑鞋,9001,类目 12,点了):

总分 = −3.0 + 0 + 0 + 0 + 0 = −3.0     →   p = 4.74%
误差 = 0.0474 − 1 = −0.953
基础分 = −3.0 − 0.5 × (−0.953) = −2.524
第 22 行(user=u123) = 0 − 0.5 × (−0.953) = +0.476
第 50 行(query=跑鞋)= +0.476,第 38 行(ad=9001)= +0.476,第 16 行(cat=12)= +0.476

预测 4.74%,实际点了,四个特征和基础分一起被调高。

样本 2(u456,口红,9003,类目 40,没点):

总分 = −2.524 + 0 + 0 + 0 + 0 = −2.524   →   p = 7.42%
误差 = 0.0742 − 0 = +0.074
基础分 = −2.524 − 0.5 × 0.074 = −2.561
第 53、1、10、57 行 = 0 − 0.5 × 0.074 = −0.037

这四个特征之前没见过,分值都是 0,所以预测只靠基础分。没点,它们各自被扣一点。

样本 3(u789,篮球鞋,9002,类目 12,没点):

总分 = −2.561 + 0 + 0 + 0 + 0.476(第 16 行 cat=12) = −2.085   →   p = 11.06%
误差 = 0.1106 − 0 = +0.111
基础分 = −2.561 − 0.5 × 0.111 = −2.616
第 48、14、28 行 = −0.055
第 16 行(cat=12) = 0.476 − 0.5 × 0.111 = 0.421

注意 cat=12 的分值:样本 1 把它推到 0.476,样本 3 把它拉回 0.421。它的最终值反映的是“类目 12 的广告到底被点得多不多”,由所有含这个类目的样本共同决定。

训练三步之后的表(只列出非零的行):

行号 特征 分值
基础分 −2.616
22 user=u123 +0.476
50 query=跑鞋 +0.476
38 ad=9001 +0.476
16 cat=12 +0.421
53 / 1 / 10 / 57 user=u456 / query=口红 / ad=9003 / cat=40 −0.037
48 / 14 / 28 user=u789 / query=篮球鞋 / ad=9002 −0.055

验证: 用更新后的表重新算样本 1:−2.616 + 0.476 + 0.476 + 0.476 + 0.421 = −0.766,p 从 4.74% 升到 31.73%。模型朝着“这种情况会点”修正了一步。再算一个没见过的组合“u123 搜口红看到广告 9003”:−2.616 + 0.476 − 0.037 − 0.037 − 0.037 = −2.251,p = 9.52%。u123 的分值是从跑鞋样本学来的,但对口红请求同样起作用,因为 LR 假设“这个人爱不爱点”和他搜什么无关。

真实训练把几十亿条样本全部走一遍,每条样本就是上面这三行算术。

5. 分值最终停在哪

假设广告 9001 的真实点击率是 10%,基础分固定为 −3.0,只训练 ad=9001 这一行。它会被推拉到一个平衡点:点了的样本往上推,没点的往下拉,两股力量抵消。

10% 的样本往上推 (1 − p),90% 的样本往下拉 p
平衡条件:0.1 × (1 − p) = 0.9 × p   →   p = 10%
对应分值:sigmoid(−3.0 + 分值) = 10%   →   分值 = 0.80

也就是说分值会停在“预估点击率等于真实点击率”的位置。这是 LR 的一个好性质:训练充分之后,对任何一个特征,含它的样本的预估点击率之和等于它们的真实点击数。广告系统按预估点击率计费和竞价,所以预估值的绝对大小准不准很重要。

6. 工程上还要处理的三件事

分值会被偶然事件推得很极端。 一个只出现过一次的特征(比如样本 3 里的 user=u789),一次没点就得了 −0.055;如果它只出现一次且点了,会直接得到 +0.476,和 user=u123 一样高。一次曝光说明不了什么,但模型不知道。解决办法是正则化:给每个分值加一个“往 0 拉”的力。

正则 做法 效果
L2 每步更新后分值乘以一个略小于 1 的数,比如 0.476 × (1 − 0.5 × 0.01) = 0.474 所有分值整体偏小,出现次数少的特征被压得更狠
L1 每步把分值往 0 拉一个固定量,比如 0.05;绝对值小于 0.05 的直接归 0 大量不重要的特征分值精确等于 0,表里不用存,模型变小

用 L1 之后,上面 user=u789 的 −0.055 会被拉到 −0.005,再来一步就归零;而 cat=12 这种被多条样本反复推高的分值受影响很小。

特征取值有几十亿个,表太大。 对特征取哈希再对固定行数取模,就是第 2 节的做法。代价是不同特征可能撞到同一行。64 行的表里 hour=晚间 的 CRC32 是 … % 64 = 16,和 cat=12 撞在一起,两者会共用一个分值。真实模型用几亿行,撞车概率很小,且训练和在线用同一个哈希,撞了也只是精度略降,不会出错。

新广告、新词不断出现,模型要跟得上。 在线学习:训练程序持续消费实时样本流,不断更新分值,每隔几分钟把变化的行推到线上。工业界常用 FTRL 算法,它在在线更新的同时能保持 L1 的稀疏效果,还会给每个特征单独调整步长——出现次数多的特征步子小,新特征步子大。

三、在线:一次请求怎么变成打分

在线推理就是“生成特征、查表、相加、换算”,没有矩阵运算,不需要 TensorFlow 这类框架。每个候选的计算量和它的特征个数成正比。

1. 请求带来的原始数据

场景:用户 u123 在晚上 21 点搜“跑鞋”,有 2 个候选广告 9001 和 9002。

来源 原始数据
请求本身 用户 u123,搜索串“跑鞋”,时间 21 点
用户特征存储 性别 男
物料表,广告 9001 类目 12(跑鞋),价格 299 元,7 天点击率 0.031
物料表,广告 9002 类目 15(篮球鞋),价格 1299 元,7 天点击率 0.008

2. FG 按配置生成特征

配置里写着模型用哪些特征、每个特征怎么算。和 DeepFM 那篇的 FG 完全相同,区别只在最后一步:LR 不需要拼张量,特征直接变成“特征名=取值”字符串再哈希。

特征 作用域 算子 结果
user 请求级 直接取值 user=u123
query 请求级 直接取值 query=跑鞋
hour 请求级 分桶 hour=晚间
gender 请求级 直接取值 gender=男
cat 物料级 直接取值 cat=12;cat=15
price 物料级 取 log2 取整 price=8;price=10
gender_x_cat 交叉 两个取值组合 gender=男_cat=12;gender=男_cat=15
query_x_cat 交叉 两个取值组合 query=跑鞋_cat=12;query=跑鞋_cat=15

FG 的输出是每个候选一份特征列表:

候选 0(广告 9001):[user=u123, query=跑鞋, hour=晚间, gender=男, cat=12, price=8, gender=男_cat=12, query=跑鞋_cat=12]
候选 1(广告 9002):[user=u123, query=跑鞋, hour=晚间, gender=男, cat=15, price=10, gender=男_cat=15, query=跑鞋_cat=15]

请求级的四个特征两个候选完全一样,只需要算一次、哈希一次、查一次表。连续值(7 天点击率 0.031)在 LR 里也要先分桶变成离散取值(比如 ctr7d=3),否则没法查表;这个例子为了简短没有用它。

3. 查表、相加、换算

每个特征哈希成行号,查出分值,加起来。分值用第一节那张表,交叉特征的分值补充如下:

特征 分值
hour=晚间 +0.1
gender=男 0.0
gender=男_cat=12 +0.4
gender=男_cat=15 −0.3
query=跑鞋_cat=12 +0.6
query=跑鞋_cat=15 −0.5
候选 0(9001):−6.0 + 0.2 + 0.1 + 0.1 + 0.0 + 0.5 + 0.3 + 0.0 + 0.4 + 0.6 = −3.8   →   p = 2.19%
候选 1(9002):−6.0 + 0.2 + 0.1 + 0.1 + 0.0 − 0.7 − 0.2 − 0.1 − 0.3 − 0.5 = −7.4   →   p = 0.061%

两个候选的预估点击率差了 36 倍,其中交叉特征贡献了大头:没有交叉特征时两者是 0.81% 和 0.13%。

4. 打分代码

// 一个候选的所有特征已经由 FG 哈希成整数
float Score(const std::vector<uint64_t>& feature_hashes) {
  float total = bias;                                          // 基础分
  for (uint64_t h : feature_hashes) {
    auto it = weights.find(h);                                 // 查表;查不到就是 0 分
    if (it != weights.end()) total += it->second;
  }
  return 1.0f / (1.0f + std::exp(-total));                     // sigmoid
}

新广告、新用户在表里查不到,按 0 分处理,等于“不加分也不扣分”。一次请求几百个候选,请求级特征对所有候选一样,它们的分值之和只需要算一次,再对每个候选加上物料级和交叉特征的分值。

5. 权重表怎么存、怎么更新

方面 情况
大小 L1 正则之后只有非零行需要存,几千万到几亿个“行号 → float”
存放 放得下就放进程内存,用开放寻址的哈希表;放不下就放远程键值存储,按候选批量读取
更新 整表按版本替换;或者在线学习时按“一批行号 → 新分值”增量推送,线上直接覆盖对应的行

这张表和 DeepFM 的 embedding 表是同一种东西,只是每行只有 1 个数而不是 16 个数。所以 embedding 服务里的那些做法——批量查询、先去重再查、查不到返回默认值、流式增量更新——对 LR 权重表同样适用。

6. 打分之后:校准

训练时通常会对“没点”的样本做采样(比如只留 10%),否则正样本太少、数据量太大。采样之后模型看到的点击率整体偏高,线上打分后要按一个公式还原:

训练看到的:预估 p' = 5%   →   赔率 1 : 19
采样把不点的样本砍到 10%,真实的不点样本是模型看到的 10 倍:真实赔率 = 1 : 190
还原后:p = 1 / (1 + 190) = 0.52%

有的系统还会再套一层按分数区间的线性或分段修正,对齐预估值和线上真实点击率。校准放在打分之后、排序和计费之前。

四、LR 的局限:分值不会因人而异

LR 不会个性化:广告 9001 的 +0.5 对所有人都一样,模型表达不了“这个广告对这个人合不合适”。

1. 用例子看这个缺陷

两个用户看同一个跑鞋广告,一个爱运动,一个只看美妆。LR 给出的总分只差在“用户本身爱不爱点广告”这一项上:

爱运动的用户 只看美妆的用户
基础分 −6.0 −6.0
用户分值 +0.2 0
搜索词分值 +0.1 +0.1
广告分值 +0.5 +0.5
类目分值 +0.3 +0.3
总分 −4.9 −5.1
预估点击率 0.74% 0.61%

真实情况可能是 8% 和 0.4%,差 20 倍;LR 只能给出 0.74% 和 0.61%。

2. LR 时代的补救:人工交叉特征

把两个特征拼成一个新特征,让它有自己的分值,就是第三节里的 gender=男_cat=12。这样“男性看跑鞋类目”和“女性看跑鞋类目”可以有不同的分值,模型有了一部分个性化能力。

代价有三个:

  • 要靠人去想该交叉哪些特征,一个个试。
  • 交叉后的取值数是两边取值数的乘积,表会急剧膨胀。“user × ad”这种细粒度的交叉几乎学不出来,因为绝大多数组合从没出现过。
  • 没在样本里一起出现过的组合没有分值:新用户看老广告,LR 完全不知道。

3. 从 LR 到 FM 到 DeepFM

模型 总分的组成 补了什么
LR 基础分 + 各特征分值之和
FM LR + 所有两两特征的向量内积之和 用向量内积代替人工交叉:每个特征学一个向量,任意两个特征的匹配程度用内积表示,没一起出现过的组合也能算出分数
DeepFM FM + 多层网络的输出 三个以上因素的复杂组合

三者的最后一步都一样:把总分过 sigmoid 换算成概率。训练的规则也一样,都是“预估减结果”乘以各自的梯度。

LR 到今天仍然有用:它算得极快、容易解释、适合在线学习,常被用作粗排模型、过载时的降级模型,以及大模型里的一阶项。