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% 内容的曝光占比)”“新内容曝光占比”“创作者覆盖数”这类结构性指标。

关联