63.4 冷启动与实时性

一句话

冷启动的本质是信息不足下的探索:不是“没有数据所以没法推荐”,而是“要在有限的曝光里,用最少的代价试出哪些新内容值得推”——这是多臂老虎机问题,不是排序问题。

三种冷启动

类型 缺什么 可用信号 常用解法
新用户 行为历史 设备、地域、注册渠道、首屏点击 热门 + 规则 + 快速试探(EE)
新物品 点击/转化数据 内容属性、类目、作者、文本向量 内容相似召回 + 探索流量
新系统 全都没有 业务规则、运营经验 规则兜底 + 小流量 A/B

🔑 三者的解法不同:新用户靠“问”(引导选择)与“快试”,新物品靠“内容相似”与“探索配额”,新系统靠“规则先跑、数据后补”。

代码:三种探索策略谁先找到好内容

package main

import (
	"fmt"
	"math"
	"math/rand"
	"sort"
)

const impressions = 20000 // 总曝光

type arm struct {
	trueCTR float64 // 真实的点击率(只有上帝知道)
	shown   int
	clicks  int
}

func (a *arm) mean() float64 {
	if a.shown == 0 {
		return 0
	}
	return float64(a.clicks) / float64(a.shown)
}

type result struct {
	name   string
	clicks int
}

func genArms(n int, seed int64) []*arm {
	r := rand.New(rand.NewSource(seed))
	as := make([]*arm, n)
	for i := range as {
		as[i] = &arm{trueCTR: 0.01 + r.Float64()*0.19} // 1%–20%
	}
	return as
}

func reset(as []*arm) {
	for _, a := range as {
		a.shown, a.clicks = 0, 0
	}
}

func run(nArms int) []result {
	as := genArms(nArms, 42) // 所有策略面对同一批物品
	r := rand.New(rand.NewSource(7))
	click := func(a *arm) int {
		a.shown++
		if r.Float64() < a.trueCTR {
			a.clicks++
			return 1
		}
		return 0
	}

	var results []result

	// ① 随机
	reset(as)
	total := 0
	for t := 0; t < impressions; t++ {
		total += click(as[r.Intn(nArms)])
	}
	results = append(results, result{"随机", total})

	// ② ε-greedy(10% 探索)
	reset(as)
	total = 0
	for t := 0; t < impressions; t++ {
		var pick int
		if r.Float64() < 0.1 {
			pick = r.Intn(nArms)
		} else {
			pick = 0
			for i := 1; i < nArms; i++ {
				if as[i].mean() > as[pick].mean() {
					pick = i
				}
			}
		}
		total += click(as[pick])
	}
	results = append(results, result{"ε-greedy(0.1)", total})

	// ③ UCB1:先各试一次,再按 mean + sqrt(2 ln t / n) 选
	reset(as)
	total = 0
	for i := 0; i < nArms; i++ {
		total += click(as[i])
	}
	for t := nArms; t < impressions; t++ {
		best, bestScore := 0, -1.0
		for i, a := range as {
			score := a.mean() + math.Sqrt(2*math.Log(float64(t))/float64(a.shown))
			if score > bestScore {
				best, bestScore = i, score
			}
		}
		total += click(as[best])
	}
	results = append(results, result{"UCB1", total})
	return results
}

func main() {
	for _, nArms := range []int{10, 100} {
		as := genArms(nArms, 42)
		bestCTR := 0.0
		for _, a := range as {
			if a.trueCTR > bestCTR {
				bestCTR = a.trueCTR
			}
		}
		ideal := int(bestCTR * impressions)

		results := run(nArms)
		sort.Slice(results, func(i, j int) bool { return results[i].clicks > results[j].clicks })
		fmt.Printf("=== %d 个新物品、%d 次曝光(最优物品真实 CTR %.1f%%)===\n",
			nArms, impressions, bestCTR*100)
		fmt.Printf("%-18s %-10s %s\n", "策略", "点击", "相对上界")
		for _, r := range results {
			fmt.Printf("%-18s %-10d %.1f%%\n", r.name, r.clicks,
				float64(r.clicks)*100/float64(ideal))
		}
		fmt.Println()
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 10 个新物品、20000 次曝光(最优物品真实 CTR 16.4%)===
策略                 点击         相对上界
ε-greedy(0.1)      3018       91.8%
UCB1               2369       72.0%
随机                 1632       49.6%

=== 100 个新物品、20000 次曝光(最优物品真实 CTR 19.9%)===
策略                 点击         相对上界
ε-greedy(0.1)      3582       89.8%
UCB1               2560       64.2%
随机                 2139       53.6%

读数:

策略 10 个物品 100 个物品 说明
随机 49.6% 53.6% 一半曝光浪费在差内容上
ε-greedy(0.1) 91.8% 89.8% 简单、收敛快,把 90% 的曝光压在经验最优上
UCB1 72.0% 64.2% 强制探索太贵:2 万次曝光下,探索项一直很大

这张表最重要的结论与直觉相反:在“2 万次曝光”这个时间尺度上,UCB1 反而不如 ε-greedy。原因是 UCB 的探索项 sqrt(2 ln t / n) 随 t 增长,对“试得少的臂”持续加分,2 万次曝光分摊到 10–100 个臂后,每个臂只有几百到几千次,探索项始终与 CTR 同量级,于是它不停地在“再试一次”和“压最好的”之间摇摆。UCB 的 regret 上界是渐近最优的,但短周期、大臂数下并不占优。

🔑 冷启动的取舍是探索成本 vs 收敛速度,而且没有永远更优的策略:随机最浪费;ε-greedy 短周期最有效;UCB 在长周期、少臂时才有优势。生产上的稳妥做法是“分层”:新物品给固定探索配额(如 5% 流量),配额内用 ε-greedy 或汤普森采样快速收敛,配额用完按真实 CTR 排序。

实时性:三条时间尺度

尺度 更新什么 延迟要求 机制
请求级 上下文特征、去重、频控 毫秒 在线计算
近线(分钟) 用户实时行为、物品实时 CTR 1–5 分钟 流处理(62.1 流处理模型与时间语义)
离线(小时/天) 模型训练、长期画像 小时–天 批处理

⚠️ 最常见的“实时性”误区:模型每天更新,但特征实时才算实时推荐;只把模型改成小时级、特征仍是 T+1,效果提升有限。实时性的收益主要来自“行为特征”,不是“模型参数”。

反模式

反模式 后果
新内容零曝光(不探索) 好内容永远起不来,生态僵化
探索流量不设上限 用户体验被“实验”伤害
用点击率直接定生死(曝光少) 小样本噪声把好内容误杀
冷启动只做规则,不做收敛 长期停留在“热门推荐”
实时特征与离线训练口径不同 线上线下不一致(63.3)
不做频控 同一内容反复出现,体验差

自检

Q1 为什么“新物品给固定 5% 探索流量”比“完全按 CTR 排序”更合理?

▸ 答案:因为新物品没有曝光就没有 CTR,没有 CTR 就永远排不上——这是“富者愈富”的马太效应。固定探索配额保证每个新物品都有机会获得初始曝光,用少量流量换取“发现好内容”的可能。代价是 5% 的流量可能被浪费在差内容上,这是必要成本。

Q2 ε-greedy 与 UCB 的核心差别是什么?

▸ 答案:ε-greedy 以固定概率随机探索,不区分哪个臂更值得试;UCB 按“均值 + 不确定性”打分,对“试得少、可能很好”的臂给更高分,探索更有针对性。代价是 UCB 要维护每个臂的计数与置信区间,且对非平稳分布(兴趣漂移)不敏感——生产上常配合滑动窗口或汤普森采样。

Q3 一个新上线的推荐系统,冷启动阶段最该先做什么?

▸ 答案:先做规则兜底 + 数据采集:用热门/编辑精选保证“有内容可看”,同时把曝光、点击、停留、负反馈全部打点采全。没有数据,任何模型都无从训练;有了数据,再逐步引入协同过滤/向量召回,并用小流量 A/B 验证(63.5)。顺序反了(先上复杂模型、没有数据)就会得到“模型效果不如热门”的结论。

关联