63.6 偏差、反馈回路与多样性
一句话
推荐系统在用自己产生的数据训练自己:位置越靠前点击越多 → 模型越爱推它 → 它越靠前。这个正反馈回路会把“位置带来的点击”误当成“内容本身的吸引力”,最终让列表越来越窄、越来越旧。
四种偏差
| 偏差 | 来源 | 后果 | 缓解 |
|---|---|---|---|
| 位置偏置 | 排前面天然点击高 | 模型学到“位置”而非“相关性” | 训练时降权/IPS、随机化曝光 |
| 样本选择偏差 | 只记录被曝光过的 | 未曝光的好内容永远没数据 | 探索流量、因果推断 |
| 流行度偏差 | 热门积累更多数据 | 马太效应,长尾消失 | popularity 校正、多样性约束 |
| 反馈回路 | 用自身输出训练 | 越推越窄,兴趣固化 | 探索、去偏、定期重训 |
🔑 四种偏差的共同根因:观测到的数据不是随机实验的结果。解法也只有一类——在数据产生阶段引入随机性(探索/随机化),或在训练阶段做校正(IPS/因果)。
代码:位置偏置如何骗过模型
package main
import "fmt"
type item struct {
name string
trueCTR float64
}
var items = []item{
{"A(内容好)", 0.10},
{"B(内容一般,但被放在第一位)", 0.05},
}
// 位置偏置:第 1 位 1.0,第 2 位 0.3
var bias = []float64{1.0, 0.3}
func main() {
// 线上策略:B 固定排第一(运营位),A 排第二
order := []int{1, 0} // 先 B 后 A
impressions := 100_000.0
fmt.Println("=== 线上日志(含位置偏置)===")
fmt.Printf("%-30s %-10s %-10s %-12s %s\n", "物品", "位置", "真实CTR", "记录CTR", "点击数")
logged := make([]float64, len(items))
for pos, idx := range order {
ctr := items[idx].trueCTR * bias[pos]
logged[idx] = ctr
fmt.Printf("%-30s %-10d %-10.2f%% %-12.2f%% %.0f\n",
items[idx].name, pos+1, items[idx].trueCTR*100, ctr*100, ctr*impressions)
}
// 朴素估计:直接用记录 CTR 排序
fmt.Println("\n=== 朴素估计(直接用日志 CTR)===")
if logged[0] > logged[1] {
fmt.Println("结论:A 排第一(正确,但纯属巧合——A 的真实优势足够大)")
} else {
fmt.Println("结论:B 排第一 ← 被位置偏置骗了:B 的高点击来自位置,不是内容")
}
// IPS 校正:除以该位置被选中的倾向(这里用位置偏置的倒数)
fmt.Println("\n=== IPS 校正(记录 CTR ÷ 位置偏置)===")
for idx := range items {
pos := 0
for p, i := range order {
if i == idx {
pos = p
}
}
corrected := logged[idx] / bias[pos]
fmt.Printf("%-30s 校正后 CTR %.2f%%(真实 %.2f%%)\n",
items[idx].name, corrected*100, items[idx].trueCTR*100)
}
// 反馈回路:连续 5 轮,每轮按朴素 CTR 排序,A 永远排第二
fmt.Println("\n=== 反馈回路:5 轮之后曝光分配 ===")
exposure := []float64{0, 0}
cur := []int{1, 0} // 初始:B 第一
for round := 1; round <= 5; round++ {
// 每轮 2 万次曝光,第一名拿 80% 曝光
first, second := cur[0], cur[1]
exposure[first] += 16_000
exposure[second] += 4_000
// 下一轮按“记录 CTR”排序(B 的记录 CTR 一直被位置抬高)
cur = []int{first, second}
fmt.Printf("第 %d 轮:%s 曝光 %.0f,%s 曝光 %.0f\n", round,
items[first].name[:1], exposure[first], items[second].name[:1], exposure[second])
}
fmt.Println("\nA 的真实 CTR 是 B 的两倍,却只拿到 20% 的曝光——这就是反馈回路")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 线上日志(含位置偏置)===
物品 位置 真实CTR 记录CTR 点击数
B(内容一般,但被放在第一位) 1 5.00 % 5.00 % 5000
A(内容好) 2 10.00 % 3.00 % 3000
=== 朴素估计(直接用日志 CTR)===
结论:B 排第一 ← 被位置偏置骗了:B 的高点击来自位置,不是内容
=== IPS 校正(记录 CTR ÷ 位置偏置)===
A(内容好) 校正后 CTR 10.00%(真实 10.00%)
B(内容一般,但被放在第一位) 校正后 CTR 5.00%(真实 5.00%)
=== 反馈回路:5 轮之后曝光分配 ===
第 1 轮:B 曝光 16000,A 曝光 4000
第 2 轮:B 曝光 32000,A 曝光 8000
第 3 轮:B 曝光 48000,A 曝光 12000
第 4 轮:B 曝光 64000,A 曝光 16000
第 5 轮:B 曝光 80000,A 曝光 20000
A 的真实 CTR 是 B 的两倍,却只拿到 20% 的曝光——这就是反馈回路
读数:
| 物品 | 真实 CTR | 记录 CTR | IPS 校正 |
|---|---|---|---|
| A(第 2 位) | 10% | 3% | 3%/0.3 = 10% |
| B(第 1 位) | 5% | 5% | 5%/1.0 = 5% |
🔑 位置偏置能把“真实 CTR 差一倍”的两个物品,在日志里压成“看起来差不多甚至反超”。这就是为什么必须随机化一部分曝光(哪怕 5%),或者在训练时用 IPS/位置作为特征做校正——否则模型永远学不到“内容本身好不好”。
多样性与生态:别让回路越收越窄
| 维度 | 度量 | 目标 |
|---|---|---|
| 类目多样性 | 列表内不同类目数 / 熵 | 不一屏同类 |
| 内容新鲜度 | 新内容曝光占比 | 给新内容机会 |
| 创作者/商家覆盖 | 曝光覆盖的创作者比例 | 生态健康 |
| 兴趣广度 | 用户长期兴趣类目数 | 不做“信息茧房” |
⚠️ 多样性与短期 CTR 常常冲突:打散会降点击,但能提升长期留存与生态健康。所以多样性指标要作为护栏指标进 A/B(63.5),而不是“和 CTR 一起优化”。
反模式
| 反模式 | 后果 |
|---|---|
| 训练数据直接用线上日志,不做去偏 | 模型学位置,不学内容 |
| 没有探索流量 | 新内容永远没数据 |
| 只优化 CTR,不看多样性 | 列表越来越窄,用户流失 |
| 位置作为特征喂给模型却不做校正 | 线上换位置后效果崩 |
| 用“历史点击”判断内容好坏 | 富者愈富,长尾消失 |
| 反馈回路不监控 | 半年后推荐质量悄悄退化 |
自检
Q1 为什么“用线上日志训练排序模型”会把位置偏置学进去?
▸ 答案:因为日志里只记录“在这个位置是否被点击”,没有记录“如果放在别的位置会不会被点击”。模型要拟合点击,最省力的方式是记住“位置越靠前越可能被点”,于是把位置当成强特征。上线后一旦改变布局(或去掉运营位),模型的预测就失效。解法是随机化曝光(让位置与内容解耦)或用 IPS 把“位置效应”除掉。
Q2 IPS 校正为什么能还原真实 CTR?它的代价是什么?
▸ 答案:IPS(逆倾向加权)把每条记录的贡献除以“它被展示在该位置的概率”,使高位展示的样本被降权、低位展示的样本被升权,从而还原“无偏”的分布。代价是方差大:低位样本权重很大,少量样本会主导估计;如果某些位置几乎没有样本(倾向为 0),估计会爆炸。所以实践中要配合裁剪(权重上限)、探索流量与置信区间。
Q3 反馈回路造成的“越推越窄”,为什么短期指标看不出来?
▸ 答案:因为回路是慢变量:短期内被推的内容点击率确实高(用户确实爱看),CTR 指标好看;但用户的兴趣被固化、新内容没有机会、创作者流失,这些影响体现在数月后的留存与内容供给上。要提前发现,需要监控“曝光集中度(Top 1% 内容的曝光占比)”“新内容曝光占比”“创作者覆盖数”这类结构性指标。
关联
- 前置:63.2 召回 / 排序 / 重排、63.5 推荐效果评估与 A/B
- 并列:63.4 冷启动与实时性、63.3 特征与在线服务
- 下游:63.2 召回 / 排序 / 重排、63.5 推荐效果评估与 A/B、64.3 模型服务与 A/B
- 待补:70.2 Feed 流架构 (推 / 拉 / 推拉结合)(未写,无链接)