前两篇讲了召回的全景和最经典的个性化召回 ItemCF。这一篇讲召回里最简单、最便宜、却永远不会被淘汰的一路——热门召回(Popularity-based Recall)。延续「录像带出租店」的比喻,这次的主角是店门口那块「本周热租榜」和管榜的「热门榜小哥」。
Table of contents
Open Table of contents
一句话理解
热门召回 = 录像带店门口那块「本周热租榜」。
它不管你是谁、看过啥,只干一件事:「把全店租得最多的片,从高到低列出来。」
当老挑片员(ItemCF)对你束手无策时——你是新客、没历史、或者她的账本恰好没覆盖到你——热门榜小哥永远能甩给你一句:「那就先看看大家都在租的这些吧。」
它是整个推荐系统里最简单、最便宜、却永远不会被淘汰的一路召回——因为它是兜底的「保险栓」。
Level 1:店门口的热租榜 — 什么是热门召回
回到那家开了 20 年的录像带出租店。
老挑片员(ItemCF)很厉害,但她有个毛病:你得先有租赁历史,她才能给你推荐。新客第一次进门、或者那种一年才来一次的稀客,她翻遍账本也算不出「铁哥们榜」。
于是店里还雇了个热门榜小哥。他的工作朴素到极点:
每周一早上,他翻一遍上周所有租赁单子,数一数:
《阿凡达》 租了 560 次 ← 冠军
《教父》 租了 480 次
《好家伙》 租了 450 次
《盗梦空间》 租了 420 次
...
然后把这个榜单抄在店门口的黑板上。
任何客人进门,只要老挑片员搞不定,热门榜小哥就指着黑板说:「喏,大家都在租这些,你也看看?」
热门召回的核心信念(一句话)
| 信念 | 通俗说法 |
|---|---|
| 从众即安全 | 「大多数人选的东西,大概率不会太差」 |
注意:热门榜小哥完全不做个性化。给张三和李四看的榜单一模一样(唯一的区别是各自跳过自己已经看过的)。这既是它的弱点,也是它的优点——简单、稳定、永远有结果。
Level 2:别小看它 — 热门榜小哥的三个角色
很多人第一反应是「热门召回太简单了,有它没它无所谓」。大错特错。 它在系统里扛着三个谁也替不了的活:
热门召回单跑指标平平,但它扛着三个谁也替不了的活——这才是它永不被淘汰的理由。
角色 1:兜底(Fallback)—— 保证「永远有东西可推」
新客小白第一次进店
→ 历史 = []
→ 老挑片员:算不出来,返回空 []
→ 热门榜小哥:没关系,本周热租榜拿去!
这是热门召回最重要的使命:当所有个性化召回都返回空时,系统绝不能给用户一个白屏。热门榜就是那个「再差也有这个」的底线。
角色 2:冷启动(Cold Start)—— 新用户的第一印象
新用户还没产生任何行为,个性化模型无从下手。热门召回这时承担**「探路者」**的角色——先用大众爆款探一探他的口味,等他点了几下、攒出最初的几条历史,再逐步交给 ItemCF / 双塔接管:
新用户进来(历史 = [])
│
▼ 第 1 屏:纯热门(大众爆款,命中率最稳)
│ 用户点了《盗梦空间》《星际穿越》→ 攒出 2 条历史
▼ 第 2~3 屏:热门占比下降,ItemCF 开始介入("看了盗梦的人还看了…")
│
▼ 历史足够后:个性化召回(ItemCF / 双塔)接管,热门退回兜底位
这背后是经典的 探索-利用(Explore-Exploit)权衡。热门召回是这场权衡里最稳的起手式——先用它保证不翻车,再慢慢让个性化接手。完整的冷启动策略(Bandit、look-alike、喜好问卷等)详见冷启动问题。
角色 3:保险栓 —— 给个性化召回「补底」
即使老用户,个性化召回也可能因为窗口太窄、覆盖不足而凑不满 200 个候选。这时热门榜小哥负责把候选名单填满,保证交给排序模型的候选数量充足。
一句话:热门召回是推荐系统的保险栓——它单独跑指标平平,但没有它,系统在冷启动和异常情况下会直接崩。
Level 3:怎么算「热度」— 从数人头到归一化
朴素版:数人头
某部片的热度 = 上周它被租了多少次
代码就一行 groupby + size:
pop = train_df.groupby("item_id").size().sort_values(ascending=False)
得到一个按热度降序的榜单:
item_id
《阿凡达》 560 ← 冠军
《教父》 480
《好家伙》 450
...
升级:把热度归一化到 [0, 1]
光有原始次数还不够。热门召回不是单独工作的——它要和 ItemCF 等其他召回路一起,把候选交给排序模型融合。但各路召回的分数量纲完全不同,直接放一起没法比。
所以热门榜小哥把所有热度除以最高热度,全部压到 (0, 1]:
max_p = pop.iloc[0] # 最热门那部的次数 = 560
score = float(c) / max_p # 每部片 = 自己的次数 / 560
| 电影 | 原始次数 | 归一化分数 |
|---|---|---|
| 《阿凡达》 | 560 | 560/560 = 1.00 |
| 《教父》 | 480 | 480/560 = 0.857 |
| 《好家伙》 | 450 | 450/560 = 0.804 |
为啥要归一化? 和 ItemCF 是同一个理由——便于多路融合。每路召回都把自己最高分当 1.0,大家话语权对等,排序模型融合时才公平。详见 ItemCF 里「4 位挑片员打分」的例子。
Level 4:热门召回的四个工程坑
「不就是按热度排序吗」——真这么想就要踩坑了。热门榜小哥 20 年下来也填了几个坑。
坑 1:马太效应 —— 越热越推,越推越热
什么是马太效应(Matthew Effect)? = 强者愈强,弱者愈弱(「富者越富」)。社会学家罗伯特·默顿 1968 年用它来描述一种正反馈循环:已经占优势的一方,会因为这个优势获得更多资源,从而优势越来越大。
放到热门召回里,这个正反馈闭环就是:
《阿凡达》本来就红 → 被推到所有人面前 → 更多人租 → 下周更红 → 推得更凶...
↑________________________________________|
这是热门召回的头号副作用:它天然制造「赢者通吃」,让冷门内容永无出头之日。表现在指标上就是 Coverage(覆盖率)极低——热门召回的 Coverage@200 只有 22.29%,意味着 78% 的电影永远不会出现在任何人的热门候选里。
解药:① 控制热门召回的融合权重(
popular: 0.3,只有 ItemCF 的 0.3 倍);② 靠内容召回 / Swing 等其它路去捞长尾。
坑 2:已看过的不能再推(已看过过滤)
榜单是全店统一的,但每个人看过的不一样。如果不过滤,会出现「把你上周刚租的《阿凡达》又推给你」的尴尬。
热门榜小哥的解法:给每个客户记一份「看过清单」,推荐时跳过:
seen = self._user_history.get(user_id, set())
# ...遍历榜单时:if item_id in seen: continue
用 set(集合)存而不是 list,是因为 in 判断集合是 O(1),列表是 O(n)——过滤要快。
坑 3:时效性 —— 榜单多久更新一次
某网红片周三突然爆火
→ 但热租榜是周一抄的
→ 这片还没进榜
→ 召不出来
热度是有时效的。榜单更新越频繁(小时级 > 天级 > 周级),越能抓住趋势,但计算成本也越高。recsys-mini 是离线一次性 fit,属于「静态热门」——够用,但抓不住实时趋势。
坑 4:用「次数」还是「人数」?—— 重复交互偏差
如果一个铁粉把《教父》反复租了 50 次,按「租赁次数」算,《教父》的热度会被一个人严重灌水——它的「热」其实只是「一个人很爱」,而不是「很多人爱」。
# ① 数交互行数——一个铁粉的 50 次重复也算 50
c_i = train_df.groupby("item_id").size()
# ② 数去重后的独立用户数——一个铁粉无论租几次都只算 1 票
c_i = train_df.groupby("item_id")["user_id"].nunique()
| 场景 | 推荐用哪个 | 为什么 |
|---|---|---|
| 评分 / 一次性消费(电影、商品) | ① 次数 | 每人对每物品基本只交互一次,两者几乎等价 |
| 可重复消费(音乐、短视频、新闻) | ② 独立用户数 | 防止「单人狂刷」把某个物品顶上榜,「多少人爱」比「被播多少次」更能代表真热度 |
Level 5:热门召回代码逐行走读
popular.py 全文只有 36 行,把上面讲的逻辑全实现了。
5.1 类定义 + fit
class PopularRecaller(BaseRecaller):
name = "popular"
def __init__(self, topk: int = 200):
self.topk = topk
self._top_items: List[Tuple[int, float]] = []
self._user_history: dict[int, set[int]] = {}
def fit(self, train_df: pd.DataFrame) -> None:
pop = train_df.groupby("item_id").size().sort_values(ascending=False)
# score 归一化到 [0, 1] 便于多路融合
max_p = pop.iloc[0]
self._top_items = [(int(i), float(c) / max_p) for i, c in pop.items()]
self._user_history = train_df.groupby("user_id")["item_id"].apply(set).to_dict()src/recall/popular.py
| 成员 | 装的是 |
|---|---|
_top_items | 全局热门榜 List[(item_id, 归一化分数)],fit 时灌满,按热度降序 |
_user_history | 每个用户看过的集合 dict[user, set[item]],推荐时去重用 |
fit 干四件事:数热度降序排 → 取最热门那部当归一化分母 → 每部转成 (item_id, 次数/max_p) → 给每个用户建「看过集合」。重活全在 fit,recall 时只查表遍历。
5.2 recall — 在线推荐
def recall(self, user_id: int, k: int) -> List[Tuple[int, float]]:
seen = self._user_history.get(user_id, set())
out: List[Tuple[int, float]] = []
for item_id, score in self._top_items:
if item_id in seen:
continue
out.append((item_id, score))
if len(out) >= k:
break
return outsrc/recall/popular.py
关键在 seen = self._user_history.get(user_id, set())——新用户取不到 → 默认空集 → 整张榜原样推,这正是兜底能力的来源。
5.3 和 ItemCF 的对比
| 维度 | Popular.recall | ItemCF.recall |
|---|---|---|
| 角色 | 兜底 / 保险栓 | 个性化主力 |
| 控制流 | 单层循环(查表取数) | 双层循环(现场累加) |
| 候选池 | 全局唯一,所有人共用 | 因用户历史而异 |
| 个性化 | ❌ 零(所有人相同,只是各自去重) | ✅ 强(每人历史不同 → 候选不同) |
| 新用户 | ✅ seen 默认空集 → 返回热门榜兜底 | ❌ if not hist: return [] 束手无策 |
| 复杂度 | O(k + |seen|) 很轻 | O(H·K + …) 重得多 |
| 实测 Recall@200 | 36.97% | 50.15% |
| 实测 Coverage@200 | 22.29%(马太效应) | 69.89% |
核心结论:ItemCF 强在「懂你」,但前提是你得有历史;Popular 弱在「千人一面」,但永远有结果。前者在「新用户」那一格交白卷,后者刚好补上——所以 MultiRecaller 把两者用 RRF 一融合,就得到了「既个性化、又永不空手」的候选名单。
Level 6:升级版的热门榜
recsys-mini 实现的是最朴素的「全局静态热门」。工业界还有几个常见升级款。
6.1 时间衰减热门 —— 「最近火的更重要」
朴素热门把所有交互一视同仁——「上个月租了 500 次」和「上周租了 500 次」算出来热度一样。但显然最近的热度更有参考价值。解法是给每次交互按「年龄」打折:
热度(i) = Σ_{i 的每次交互} exp(-α · 距今天数)
每次交互不再「算一票」,而是算 exp(-α · Δt) 票——越久远的交互,票越轻。
| 距今天数 Δt | exp(-α·Δt)(取 α≈0.099,半衰期 7 天) |
|---|---|
| 今天(0 天) | 1.00(满票) |
| 7 天前 | 0.50(半票) |
| 14 天前 | 0.25 |
| 30 天前 | ≈ 0.05(几乎不算数) |
要定期重算:
Δt是「距今」,所以今天的榜和明天的榜不一样——必须周期性重新fit,否则「距今」越来越久,整张榜一起衰减失真。
6.2 分区热门 / 分群热门 —— 「不同人群看不同的榜」
全局热门榜假设「所有人口味一样」,但现实里人群是分层的:科幻迷打开 App,全局榜却推给他全站最热的爱情片,兜底兜了个寂寞。解法是按某个维度把用户/物品分桶,每个桶单独统计一张热门榜:
| 分法 | 例子 |
|---|---|
| 按类目 | 科幻热榜、爱情热榜、纪录片热榜各一张 |
| 按地域 | 北京热榜 vs 广州热榜 |
| 按人群 | 年轻人榜 vs 中年人榜、新用户榜 vs 老用户榜 |
| 按时段 | 工作日通勤时段榜 vs 周末晚间榜 |
分区热门是一种**「伪个性化」**——不训练任何模型,只是把
groupby("item_id")换成groupby(["类目", "item_id"])多分一层组,成本几乎为零,但体验比全局热门好得多。它还顺手缓解了马太效应:N 张小榜的曝光名额加起来,比 1 张大榜能覆盖到更多长尾内容。
6.3 Wilson 下界 / 贝叶斯平滑 —— 「别被小样本骗了」
前面按计数排。但很多场景要按比率排(好评率、CTR)。这时小样本会出大乱子:
A 片:2 个人租,2 个都好评 → 好评率 = 2/2 = 100%
B 片:1000 人租,900 好评 → 好评率 = 900/1000 = 90%
朴素按好评率排,A(100%)会排在 B(90%)前面——但 A 只有 2 个样本,这个 100% 纯属运气。
解法一:Wilson 置信区间下界
用「真实好评率的置信区间下界」排序——样本越少,下界被压得越狠(信心打折)。公式(p̂ = 观测好评率,n = 样本数,z = 置信分位数,常用 1.96):
p̂ + z²/(2n) − z·√( p̂(1−p̂)/n + z²/(4n²) )
W = ────────────────────────────────────────────
1 + z²/n
直觉只有一句:在观测好评率 p̂ 上,减去一个随样本数 n 缩小的「不确定性惩罚」。代回上例:
| 片 | 观测好评率 p̂ | 样本 n | Wilson 下界 W | 排序结果 |
|---|---|---|---|---|
| A | 100% | 2 | ≈ 0.342 | 被打到 B 后面 ✅ |
| B | 90% | 1000 | ≈ 0.879 | 稳稳排在前面 ✅ |
这就是 Evan Miller 那篇经典文章 How Not To Sort By Average Rating 推广开的做法。
解法二:贝叶斯平滑(更直观的「兑水」版)
工程上更常用的等价思路——先假装每个物品天生带了 α 个好评和 β 个差评(先验,通常取全局平均好评率):
平滑好评率 = (好评数 + α) / (总数 + α + β)
样本越少,这几票先验占比越大,结果越被往「全局平均」上拉。举例(先验 α=β=5):
| 片 | 原始 | 平滑后 |
|---|---|---|
| A:2 好评 0 差评 | 100% | (2+5)/(2+10) = 58.3% |
| B:900 好评 100 差评 | 90% | (900+5)/(1000+10) ≈ 89.6% |
⚠️ 重要前提:这一招只在「按比率排序」时才用得上。按原始计数排时对应的修正是 坑 4 的「次数 vs 人数」,不是 Wilson/贝叶斯平滑。
6.4 实时热门 —— 「秒级追上突发爆款」
前面 6.1 的时间衰减还是离线批量重算的。但突发新闻、直播带货、球赛绝杀这类场景的热度是分钟级爆发的——「昨晚算好的榜」约等于「过期报纸」。解法是来一条交互就更新一次计数:
把「攒一天再统一算」换成「来一条更一次」:滑动窗口天然带时间衰减,Redis ZSET 毫秒取 Top-N。
| 关键技术 | 干啥 |
|---|---|
| 流式框架(Flink / Kafka Streams) | 消费交互事件流,做窗口聚合 |
| 滑动 / 滚动窗口 | 只统计「最近 N 分钟」,老数据自动滑出(天然带时间衰减) |
| Redis ZSET | ZINCRBY 实时累加、ZREVRANGE 毫秒取 Top-N,是热榜标准载体 |
| 近似计数(Count-Min Sketch / HLL) | 物品量极大时,用少量内存换近似热度/去重计数 |
实时热门收益最直接(秒级追热点),但工程代价也最高:要搭流式管道、维护窗口状态、处理乱序事件、防刷量。所以它通常只用在强时效频道(热搜、实时榜、大促)。
6.5 一张表看清楚
| 升级款 | 解决什么问题 | 典型场景 | 代价 |
|---|---|---|---|
| 时间衰减热门 | 老热度和新热度被一视同仁 | 大多数推荐位的默认增强 | ⭐ 改 fit 加个 exp(-α·Δt) |
| 分区 / 分群热门 | 全局榜太粗,小众群体兜底差 | 频道页 / 分地域 / 分人群 | ⭐⭐ 多 groupby 一层 |
| Wilson / 贝叶斯平滑 | 按比率排时小样本高分虚假上榜 | 评分榜 / 好评率榜 / CTR 榜 | ⭐⭐ 一个公式 / 一行除法 |
| 实时热门(流式统计) | 离线榜更新太慢,错过突发爆款 | 热搜 / 实时榜 / 直播 / 大促 | ⭐⭐⭐ 要搭流式管道 |
Level 7:热门召回的命门
| 命门 | 表现 | 解药(找谁补) |
|---|---|---|
| 零个性化 | 给所有人推一样的东西 | 个性化召回:ItemCF / 双塔;轻量改良用 6.2 分区热门 |
| 马太效应 | 长尾内容永远出不了头(Coverage 仅 22%) | 低融合权重 + 内容召回 / Swing 捞长尾 |
| 新物品冷启动 | 刚上架的片热度=0,进不了榜 | 内容召回、双塔(带物品侧特征) |
| 趋势滞后 | 静态榜抓不住突发爆款 | 6.1 时间衰减 / 6.4 实时热门 |
所以热门召回从来不单独用——它单跑
Recall@200只有 36.97%(远低于 ItemCF 的 50.15%),但作为兜底 + 补底和别的召回路并行,价值无可替代。
Level 8:在多路召回里的位置
recsys-mini 的召回路线:
ItemCF (权重 1.0) ┐
├─► RRF 融合 ─► Recall@200 = 50.20%
Popular (权重 0.3) ┘
各路实测对比(MovieLens-1M / LOO 切分)
| 召回方法 | Recall@10 | Recall@50 | Recall@200 | Coverage@200 |
|---|---|---|---|---|
| ItemCF | 5.29% | 20.08% | 50.15% | 69.89% |
| Popular(兜底) | 4.74% | 15.48% | 36.97% | 22.29% |
| RRF 多路融合 | 6.08% | 19.50% | 50.20% | 69.36% |
怎么读这张表:
- Popular 单看指标全面落后 ItemCF——很正常,它零个性化。但它的
Recall@10有 4.74%,离 ItemCF 的 5.29% 并不远——说明「大众爆款」确实能命中相当一部分用户的兴趣(这正是 Cremonesi et al., RecSys’10 的经典结论:热门基线是个「看似平庸、实则难缠」的对手)。 - 但 K 越大,Popular 掉队越明显(Recall@200 差 13.2pt)——热门召回的「有效深度」很浅,适合占据候选前几十个保险位,不适合铺满 200 个。
- Coverage 只有 22.29%(ItemCF 是 69.89%)——印证马太效应:78% 的长尾永远进不了任何人的候选。
- RRF 融合 = 1 + 0.3 > 1——把「全面落后」的 Popular 用低权重融进来,非但没拖累,反而把
Recall@10从 5.29% 提到 6.08%:在头部用大众爆款补上 ItemCF 漏掉的「从众型」兴趣,同时权重低不破坏整体结构。
为什么权重是 0.3 而不是 1.0?
权重高 → 候选里热门占比大 → 个性化被稀释 → 体验变「千人一面」
权重低 → 热门只在「个性化不足时」悄悄补位 → 既兜了底又不抢戏
popular: 0.3 就是这个「补位但不抢戏」的平衡点。融合机制详见 多路召回融合 RRF。
TL;DR — 三句话
-
热门召回 = 店门口的本周热租榜。 不看你是谁,只按全局热度从高到低排,再各自跳过看过的。36 行代码全实现了。
-
它最大的价值不是指标,而是「兜底」。 新用户、冷启动、候选不足时,它保证系统永远有东西可推——这是任何个性化召回都给不了的保险栓。
-
它的命门是马太效应(Coverage 仅 22%)和零个性化。 所以工业界从不单用它,而是给它一个低融合权重(0.3),让它「补位但不抢戏」,和 ItemCF、双塔、内容召回并行互补。
下一篇讲工业界真正的召回主力——双塔召回:给每位用户和每部电影各画一张「气质画像」(向量),凭什么它能突破 ItemCF 的天花板。