📑 本页目录(点开跳转)
04 · 协同过滤:最简单的算法
⏱ 25 分钟 | ⭐ 核心 | 🔨 有代码 | ✏️ 能手算
🎯 一句话
协同过滤 = 「让用户互相帮忙」。它有两个方向:「跟你像的人喜欢啥」(UserCF)和 「你喜欢的东西像啥」(ItemCF)。
🧭 两个方向,一张图讲清
⚠️ 注意 ItemCF 的「像」不是内容像,是「被同一批人喜欢」。 所以 ItemCF 可能发现「买婴儿尿布的人也买啤酒」这种内容上八竿子打不着的关联——这正是协同过滤的魔力。
✏️ 手算一遍 UserCF(5 分钟,值得做)
数据:
| 电影A | 电影B | 电影C | 电影D | |
|---|---|---|---|---|
| 小明 | 1 | 1 | 1 | 0 |
| 小刚 | 1 | 1 | 0 | ? |
| 小红 | 0 | 0 | 1 | 1 |
(1 = 喜欢,0 = 没交互)
目标:该给小刚推 C 还是 D?
第 1 步:算小刚和每个人的相似度(用 Jaccard:交集 ÷ 并集)
小刚 ∩ 小明 = {A, B} 小刚 ∪ 小明 = {A, B, C}
sim(小刚, 小明) = 2/3 = 0.67
小刚 ∩ 小红 = {} 小刚 ∪ 小红 = {A, B, C, D}
sim(小刚, 小红) = 0/4 = 0
第 2 步:给候选打分(= Σ 相似用户的相似度 × 他对该物品的评分)
C 的得分 = 0.67 × 1(小明喜欢C) + 0 × 1(小红喜欢C) = 0.67
D 的得分 = 0.67 × 0(小明没看D) + 0 × 1(小红喜欢D) = 0
结论:推 C。 ✅
你刚刚完整地跑了一遍 UserCF。就这么回事。
📐 公式(配人话翻译)
UserCF
$$\hat{r}_{u,i} = \frac{\sum_{v \in N(u)} \text{sim}(u,v) \cdot r_{v,i}}{\sum_{v \in N(u)} |\text{sim}(u,v)|}$$
💡 人话:
「跟我像的那批人(N(u)),他们给物品 i 打了多少分,按『像的程度』加权平均一下,就是我的预测分。」 分母是归一化,防止相似用户多的人分数虚高。
ItemCF
$$\hat{r}_{u,i} = \frac{\sum_{j \in I(u)} \text{sim}(i,j) \cdot r_{u,j}}{\sum_{j \in I(u)} |\text{sim}(i,j)|}$$
💡 人话:
「我看过的东西(I(u)),谁跟物品 i 像,就按像的程度,把我给它的分加权过去。」
两个公式长得一样,只是「找相似」的对象换了:一个找人,一个找物。
🔬 相似度怎么算(三选一,够用了)
| 方法 | 公式 | 什么时候用 |
|---|---|---|
| 余弦相似度 | $\frac{A \cdot B}{\|A\|\|B\|}$ | 默认选它。有评分值时用 |
| Jaccard | $\frac{\|A \cap B\|}{\|A \cup B\|}$ | 只有 0/1 隐式反馈时用 |
| 皮尔逊相关 | 余弦,但先减去均值 | 显式评分,且用户打分习惯差异大时(有人只打 4-5 分) |
import numpy as np
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
def jaccard(a, b):
a, b = set(np.where(a > 0)[0]), set(np.where(b > 0)[0])
return len(a & b) / (len(a | b) + 1e-8)
def pearson(a, b):
mask = (a > 0) & (b > 0) # 只在都评过分的物品上算
if mask.sum() < 2: return 0
return np.corrcoef(a[mask], b[mask])[0, 1]
⚔️ UserCF vs ItemCF:怎么选(⭐ 面试常问)
| UserCF | ItemCF | |
|---|---|---|
| 适合 | 用户少、物品多且变化快 | 物品相对稳定,用户多 |
| 典型场景 | 新闻资讯(内容时效强) | 电商、视频(商品/影片长期存在) |
| 推荐特点 | 社会化、发现惊喜、跟随热点 | 个性化强、可解释、稳定 |
| 可解释性 | 弱(「和你像的人」是谁?) | 强(「因为你看了X」)✅ |
| 计算量 | 用户矩阵 O(U²),用户一多就爆 | 物品矩阵 O(I²),可离线算好缓存 ✅ |
| 实时性 | 差(用户相似度难实时更新) | 好(物品相似度隔天更新即可)✅ |
🏆 工业界的答案:ItemCF 用得多得多
三个原因: 1. 物品相似度可以离线算好,存成表,线上只需查表 + 加权 → 快 2. 可解释:「因为你看了《流浪地球》」,用户接受度高 3. 物品数量通常比用户少,且新用户一有行为就能立刻推荐(UserCF 要等他跟别人产生足够重叠)
亚马逊 2003 年的论文 Item-to-Item Collaborative Filtering 就是靠这个撑起了整个推荐业务,至今仍是很多系统的召回通路之一。
🔨 动手:完整的 ItemCF(带工业级改进)
import numpy as np
from collections import defaultdict
class ItemCF:
def __init__(self, alpha=0.5, k=20):
"""
alpha: 热门惩罚强度。0=不惩罚, 0.5=常用值, 1=强惩罚
k: 每个物品只保留最相似的 k 个(截断,省内存 + 去噪)
"""
self.alpha, self.k = alpha, k
def fit(self, user_items):
"""user_items: {user_id: [item_id, ...]}"""
# 1) 统计共现:多少人同时喜欢 i 和 j
cooccur = defaultdict(lambda: defaultdict(float))
popularity = defaultdict(int)
for user, items in user_items.items():
# 🔑 改进1: 活跃用户降权。一个买了1000本书的人,
# 他的任意两本书之间的"关联"其实很弱
weight = 1.0 / np.log1p(len(items))
for i in items:
popularity[i] += 1
for j in items:
if i != j:
cooccur[i][j] += weight
# 2) 共现 → 相似度(余弦形式 + 热门惩罚)
self.sim = {}
for i, related in cooccur.items():
scores = {}
for j, c in related.items():
# 🔑 改进2: 除以流行度,惩罚"人人都喜欢"的热门物品
scores[j] = c / ((popularity[i] ** self.alpha) *
(popularity[j] ** (1 - self.alpha)))
# 🔑 改进3: 只留 top-k,砍掉长尾噪声
self.sim[i] = dict(sorted(scores.items(), key=lambda x: -x[1])[:self.k])
return self
def recommend(self, history, n=10):
scores = defaultdict(float)
seen = set(history)
for i in history:
for j, s in self.sim.get(i, {}).items():
if j not in seen:
scores[j] += s
return sorted(scores.items(), key=lambda x: -x[1])[:n]
# ---- 试一下 ----
data = {
"u1": ["科幻A", "科幻B", "科幻C", "热门X"],
"u2": ["科幻A", "科幻B", "热门X"],
"u3": ["动画P", "动画Q", "热门X"],
"u4": ["动画P", "动画Q", "动画R", "热门X"],
"u5": ["科幻A", "科幻C", "热门X"],
}
model = ItemCF(alpha=0.5, k=10).fit(data)
print("看过 科幻A 的人,接下来推:")
for item, score in model.recommend(["科幻A"], n=3):
print(f" {item}: {score:.3f}")
🔑 三个改进为什么重要(这是新手和熟手的差距)
改进 1:活跃用户降权(IUF)
一个用户买了 1000 本书,你不能说这 1000 本书两两相似。他的每一条行为都应该更轻。
$$w_u = \frac{1}{\log(1 + |I(u)|)}$$
改进 2:热门物品惩罚
不加这个,「热门X」会和所有东西都相似(因为人人都看),推荐结果永远是几个爆款。
$$\text{sim}(i,j) = \frac{|U(i) \cap U(j)|}{|U(i)|^{\alpha} \cdot |U(j)|^{1-\alpha}}$$
α = 0.5→ 标准余弦,均衡α > 0.5→ 更狠地压热门,多样性↑ 但准确率可能↓
🎚️ 这是你上线后最常调的旋钮之一。
🎁 进阶预告:工业界还有一个比 ItemCF 更鲁棒的 i2i 算法 —— 阿里的 Swing(利用「用户对的重合度」过滤巧合共现)。等你学完主线,在 第 18 节·专题二 有完整讲解和实现。
改进 3:Top-K 截断
1 亿物品的两两相似度是 10^16 个数,存不下。只留每个物品最相似的 20–100 个,既省空间又去噪。
🕳️ 协同过滤的致命缺陷(这是后面所有算法出现的原因)
| 缺陷 | 具体表现 | 后面怎么解决 |
|---|---|---|
| 冷启动 | 新用户没行为、新物品没人看 → 完全推不了 | 内容特征、第 13 节 |
| 稀疏性 | 99.9% 的格子是空的,共现统计极不可靠 | 矩阵分解(第 5 节) |
| 无法用副信息 | 用户年龄、物品类目、时间…全都用不上 | 特征化模型(第 7、8 节) |
| 不可泛化 | 没共现过就是 0,学不到「潜在」关系 | Embedding(第 5 节) |
| 计算爆炸 | 物品一多,两两相似度算不动 | 向量检索 ANN(第 10 节) |
🔑 关键洞察:ItemCF 的相似度是「数出来的」。 下一节的矩阵分解,相似度是「学出来的」——这是整个推荐算法史上最重要的一次跃迁。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 数学原理 04 | CF 是「惰性学习」的典型:训练只是存数据,预测时才算 |
| 数学原理 05 | 为什么高维上「最近邻不再更近」—— CF 稀疏时失效的几何原因 |
| 数学原理 06 | 相似度函数的一般形式;RBF 核就是高斯加权的 kNN |
✅ 检查点
- 手算题:用户 X 喜欢 {A,B},用户 Y 喜欢 {A,B,C},用户 Z 喜欢 {C,D}。UserCF 该给 X 推什么?
- 为什么工业界更爱用 ItemCF?(说出 3 个理由)
- 热门惩罚的 α 调大,会发生什么?
- 协同过滤最致命的缺陷是什么?
👀 答案
1. 推 C。sim(X,Y)=2/3,sim(X,Z)=0。C 的分 = 2/3 × 1 = 0.67,D 的分 = 0。 2. ① 相似度可离线算好缓存,线上快;② 可解释「因为你看了X」;③ 物品比用户稳定,新用户一有行为就能推。 3. 热门物品得分被压得更低 → 推荐更小众、多样性↑,但整体准确率/点击率可能↓。经典的准确性-多样性权衡。 4. 冷启动 + 无法利用副信息(年龄、类目等)。🛑 可以停在这里
⚡ 走神救援
协同过滤两个方向:UserCF(像你的人喜欢啥)、ItemCF(你喜欢的像啥)。工业界爱 ItemCF:可离线算、可解释、更稳。三个必备改进:活跃用户降权、热门惩罚(α)、top-k 截断。致命缺陷是冷启动 + 用不了副信息 → 所以有了下一节的矩阵分解。⭐ 三个改进的分量比算法本身重:活跃用户降权(防一个刷子污染所有相似度)、热门惩罚 α(不加的话热门和谁都像)、top-k 截断(长尾噪声比信号多)。⭐⭐ 致命缺陷要理解成「后面所有算法的出发点」:CF 只会数共现,没见过的组合它一无所知,也用不了物品的内容和用户的画像。下一节的矩阵分解把「数出来的相似度」变成「学出来的向量」,从此有了泛化能力 —— 这是整个推荐算法史上最重要的一次跃迁。
下一节 👉 05-矩阵分解.md