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)。顺序反了(先上复杂模型、没有数据)就会得到“模型效果不如热门”的结论。