Skip to content
Charles Shao
Go back

热门召回:推荐系统最便宜也最不可替代的兜底保险栓

views

前两篇讲了召回的全景和最经典的个性化召回 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
电影原始次数归一化分数
《阿凡达》560560/560 = 1.00
《教父》480480/560 = 0.857
《好家伙》450450/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) → 给每个用户建「看过集合」。重活全在 fitrecall 时只查表遍历。

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.recallItemCF.recall
角色兜底 / 保险栓个性化主力
控制流单层循环(查表取数)双层循环(现场累加)
候选池全局唯一,所有人共用因用户历史而异
个性化❌ 零(所有人相同,只是各自去重)✅ 强(每人历史不同 → 候选不同)
新用户seen 默认空集 → 返回热门榜兜底if not hist: return [] 束手无策
复杂度O(k + |seen|) 很轻O(H·K + …) 重得多
实测 Recall@20036.97%50.15%
实测 Coverage@20022.29%(马太效应)69.89%

核心结论:ItemCF 强在「懂你」,但前提是你得有历史;Popular 弱在「千人一面」,但永远有结果。前者在「新用户」那一格交白卷,后者刚好补上——所以 MultiRecaller 把两者用 RRF 一融合,就得到了「既个性化、又永不空手」的候选名单。


Level 6:升级版的热门榜

recsys-mini 实现的是最朴素的「全局静态热门」。工业界还有几个常见升级款。

6.1 时间衰减热门 —— 「最近火的更重要」

朴素热门把所有交互一视同仁——「上个月租了 500 次」和「上周租了 500 次」算出来热度一样。但显然最近的热度更有参考价值。解法是给每次交互按「年龄」打折:

热度(i) = Σ_{i 的每次交互}  exp(-α · 距今天数)

每次交互不再「算一票」,而是算 exp(-α · Δt) 票——越久远的交互,票越轻

距今天数 Δtexp(-α·Δ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 置信区间下界

用「真实好评率的置信区间下界」排序——样本越少,下界被压得越狠(信心打折)。公式( = 观测好评率,n = 样本数,z = 置信分位数,常用 1.96):

       p̂ + z²/(2n) − z·√( p̂(1−p̂)/n + z²/(4n²) )
W  =  ────────────────────────────────────────────
                     1 + z²/n

直觉只有一句:在观测好评率 上,减去一个随样本数 n 缩小的「不确定性惩罚」。代回上例:

观测好评率 样本 nWilson 下界 W排序结果
A100%2≈ 0.342被打到 B 后面 ✅
B90%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 的时间衰减还是离线批量重算的。但突发新闻、直播带货、球赛绝杀这类场景的热度是分钟级爆发的——「昨晚算好的榜」约等于「过期报纸」。解法是来一条交互就更新一次计数

实时热门流式管道:交互事件流经 Kafka/Flink 滑动窗口计数,实时维护 Redis ZSET 热榜,召回时直接读取,永远是此刻最新的榜单

把「攒一天再统一算」换成「来一条更一次」:滑动窗口天然带时间衰减,Redis ZSET 毫秒取 Top-N。

关键技术干啥
流式框架(Flink / Kafka Streams)消费交互事件流,做窗口聚合
滑动 / 滚动窗口只统计「最近 N 分钟」,老数据自动滑出(天然带时间衰减)
Redis ZSETZINCRBY 实时累加、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@10Recall@50Recall@200Coverage@200
ItemCF5.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%

怎么读这张表

  1. Popular 单看指标全面落后 ItemCF——很正常,它零个性化。但它的 Recall@10 有 4.74%,离 ItemCF 的 5.29% 并不远——说明「大众爆款」确实能命中相当一部分用户的兴趣(这正是 Cremonesi et al., RecSys’10 的经典结论:热门基线是个「看似平庸、实则难缠」的对手)。
  2. 但 K 越大,Popular 掉队越明显(Recall@200 差 13.2pt)——热门召回的「有效深度」很浅,适合占据候选前几十个保险位,不适合铺满 200 个。
  3. Coverage 只有 22.29%(ItemCF 是 69.89%)——印证马太效应:78% 的长尾永远进不了任何人的候选。
  4. RRF 融合 = 1 + 0.3 > 1——把「全面落后」的 Popular 用低权重融进来,非但没拖累,反而把 Recall@10 从 5.29% 提到 6.08%:在头部用大众爆款补上 ItemCF 漏掉的「从众型」兴趣,同时权重低不破坏整体结构。

为什么权重是 0.3 而不是 1.0?

权重高 → 候选里热门占比大 → 个性化被稀释 → 体验变「千人一面」
权重低 → 热门只在「个性化不足时」悄悄补位 → 既兜了底又不抢戏

popular: 0.3 就是这个「补位但不抢戏」的平衡点。融合机制详见 多路召回融合 RRF


TL;DR — 三句话

  1. 热门召回 = 店门口的本周热租榜。 不看你是谁,只按全局热度从高到低排,再各自跳过看过的。36 行代码全实现了。

  2. 它最大的价值不是指标,而是「兜底」。 新用户、冷启动、候选不足时,它保证系统永远有东西可推——这是任何个性化召回都给不了的保险栓。

  3. 它的命门是马太效应(Coverage 仅 22%)和零个性化。 所以工业界从不单用它,而是给它一个低融合权重(0.3),让它「补位但不抢戏」,和 ItemCF、双塔、内容召回并行互补。


下一篇讲工业界真正的召回主力——双塔召回:给每位用户和每部电影各画一张「气质画像」(向量),凭什么它能突破 ItemCF 的天花板。


views
Share this post on:

Previous Post
DCO 动态创意优化与创意疲劳:模板、要素与频控
Next Post
协同过滤 ItemCF:25 年不过时的召回骨架,从余弦相似度到 Swing / EASE / 双塔