LR 模型:从打分卡到在线推理
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. 更新规则
对每一条样本做三步:
- 用当前分值算预估点击率
p。 - 算误差
p − 标签。点了,误差是负数;没点,误差是正数。 - 这条样本里出现的每个特征(包括基础分):
分值 = 分值 − 学习率 × 误差。
点了而预测低,误差是接近 −1 的负数,分值被调高;没点而预测不算低,误差是正数,分值被调低。没出现的特征分值不动。学习率控制每一步走多大,是人设定的。
这条规则不是拍脑袋定的。衡量预测好坏用的是对数损失 loss = −[y·ln(p) + (1−y)·ln(1−p)],对它求导,得到的梯度恰好就是 (p − y) × x;x 在特征出现时是 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
新广告、新用户在表里查不到,按 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 到今天仍然有用:它算得极快、容易解释、适合在线学习,常被用作粗排模型、过载时的降级模型,以及大模型里的一阶项。
暂无评论,欢迎留下第一条评论。