1. 从零拆解协同过滤:为什么基于邻域的方法至今仍是推荐系统的基石
推荐系统这个领域,每年都有新模型刷榜,从矩阵分解到深度神经网络再到各种注意力机制,论文里的指标一个比一个漂亮。但如果你真正在生产环境里搭过推荐服务,就会发现一个很有意思的现象:大量中小型产品、冷启动阶段的新业务、甚至一些日活千万级产品的召回通道,跑的还是基于邻域的协同过滤。UserCF 和 ItemCF 这两个听起来有点"古老"的算法,为什么没有被淘汰?答案其实很朴素——它们足够简单、足够可解释、足够好维护,而且在很多场景下效果并不差。
这篇文章要聊的就是这个"老而弥坚"的东西。我会把 UserCF 和 ItemCF 从直觉到公式、从相似度计算到工程落地、从参数调优到踩坑排查,完整地过一遍。适合谁看?如果你刚接触推荐系统,想找一个能真正跑起来、能理解透的入门算法,这篇就是给你写的;如果你已经做过一段时间推荐,但一直停留在"调包"层面,没搞清楚相似度矩阵到底怎么算、邻居数 K 怎么选、评分偏差怎么处理,那这篇也能帮你把地基夯实。
我个人的判断是:协同过滤不是"过时的技术",而是"被低估的基线"。任何复杂的深度模型,如果连 ItemCF 的基线都打不过,那这个模型大概率是有问题的。所以把基于邻域的方法吃透,不只是学一个算法,更是建立一套评估推荐效果的参照系。
2. 核心思想与方案选型:UserCF 和 ItemCF 到底在算什么
2.1 一句话讲清协同过滤的底层逻辑
协同过滤的核心假设特别简单:相似的人喜欢相似的东西,相似的东西被人以相似的方式喜欢。这句话拆开就是两个方向——从人的角度出发是 UserCF,从物的角度出发是 ItemCF。
打个生活化的比方。你想找一部电影看,有两种办法:第一种是问和你口味差不多的朋友最近看了什么好片(UserCF);第二种是你先看了一部喜欢的电影,然后找和它相似的其他电影(ItemCF)。两种办法都能给你推荐,但适用的场景完全不同。
这里有个关键点很多人一开始会混淆:UserCF 和 ItemCF 不是"哪个更好"的关系,而是"哪个更适合当前场景"的关系。选错了方向,效果可能差出一大截。
2.2 UserCF 与 ItemCF 的选型对照
我把两者的核心差异整理成一张表,方便你快速判断该用哪个:
| 维度 | UserCF | ItemCF |
|---|---|---|
| 核心逻辑 | 找相似用户,推荐他们喜欢的 | 找相似物品,推荐相似物品 |
| 相似度矩阵规模 | 用户数 × 用户数 | 物品数 × 物品数 |
| 适用场景 | 用户少、物品多、时效性强 | 用户多、物品少、长尾稳定 |
| 可解释性 | 较弱("和你相似的人喜欢") | 较强("因为你看了A") |
| 实时更新 | 用户兴趣变化快,需频繁更新 | 物品相似度相对稳定 |
| 典型代表 | 新闻、社交内容推荐 | 电商、视频、图书推荐 |
选型的经验法则:用户数量远小于物品数量时优先 UserCF,反之优先 ItemCF。比如一个新闻 App,用户可能几百万,但新闻每天新增几十万条,物品的相似度矩阵根本来不及算,这时候 UserCF 更合适。而一个电商平台,商品 SKU 相对稳定,用户却在不断增长,ItemCF 的相似度矩阵可以离线算好、定期更新,明显更划算。
2.3 为什么不用矩阵分解替代
有人会问,既然有 SVD、ALS 这些矩阵分解方法,为什么还要用基于邻域的?我的实测体会是三点:
第一,可解释性。ItemCF 能直接告诉你"因为你看过《A》,所以推荐《B》",这个理由用户能懂、运营能懂、产品经理也能懂。矩阵分解出来的隐向量,你很难解释第 37 维代表什么。
第二,增量更新成本低。新来一个用户行为,ItemCF 只需要更新对应的共现计数,不需要重新训练整个模型。矩阵分解往往要全量重训或者复杂的增量算法。
第三,冷启动友好度。新物品只要有少量交互,就能通过共现关系进入推荐池。矩阵分解对完全没有交互的物品基本无能为力。
当然,基于邻域的方法也有明显短板:稀疏场景下相似度计算不准、热门物品会主导推荐结果、无法捕捉深层非线性关系。这些短板正是后续各种改进和深度模型要解决的问题。但在很多业务里,这些短板的影响并没有想象中那么大。
3. 相似度计算:整个算法的发动机
3.1 三种主流相似度度量及其适用性
相似度计算是协同过滤的心脏,选错了度量方式,后面再怎么调参都是白费。常用的有三种:
余弦相似度(Cosine):把用户对物品的评分看成向量,计算两个向量夹角的余弦值。公式是 sim = (A·B) / (|A|·|B|)。它的特点是只关注方向不关注长度,适合评分尺度不一致的场景。
皮尔逊相关系数(Pearson):先减去各自的均值再算余弦,相当于做了中心化。它能消除不同用户评分习惯的偏差——有人习惯打高分,有人习惯打低分,Pearson 能把这个偏差抹平。
修正余弦相似度(Adjusted Cosine):这是 ItemCF 里最常用的。它减去的是物品的平均得分,而不是用户的。为什么?因为在 ItemCF 里,我们要衡量两个物品的相似性,但不同物品的评分基准不一样——爆款物品平均分 4.5,冷门物品平均分 3.0,直接算余弦会把这种基准差异当成相似性。
我用一个具体例子说明修正余弦的必要性。假设物品 A 被用户打分为 [5, 5, 5],物品 B 被打分为 [3, 3, 3]。如果直接算余弦,两者方向完全一致,相似度为 1。但实际上 A 是"人人都爱"的爆款,B 是"人人都觉得一般"的普通品,它们并不相似。修正余弦减去各自均值后,A 变成 [0.5, 0.5, 0.5] 附近,B 变成 [-0.5, -0.5, -0.5] 附近,相似度直接变成 -1,正确反映了它们的关系。
3.2 相似度计算的工程实现要点
理论公式好写,工程实现有几个坑必须注意。
第一个坑是零向量问题。如果某个用户对所有物品评分都是同一个值,中心化之后向量全为 0,分母为 0 会导致除零错误。处理办法是在计算前过滤掉方差为 0 的用户,或者加一个极小的平滑项。
第二个坑是稀疏矩阵的存储。真实场景下用户-物品矩阵稀疏度通常在 99% 以上,用稠密矩阵存储会直接爆内存。实践中用 scipy 的 sparse 矩阵或者自己维护倒排索引。
第三个坑是相似度矩阵的规模。ItemCF 要算物品两两相似度,如果有 10 万个物品,理论上要算 100 亿对。实际做法是:只对共现次数超过阈值的物品对计算相似度,其余直接置零。这个阈值一般设在 1 到 5 之间,具体看数据密度。
下面是一段修正余弦相似度的核心实现思路,用 Python 伪代码展示:
import numpy as np from scipy.sparse import csr_matrix def adjusted_cosine(item_user_matrix): # item_user_matrix: 物品-用户评分矩阵,行是物品,列是用户 # 计算每个物品的平均得分 item_mean = np.array(item_user_matrix.mean(axis=1)).flatten() # 中心化:每个评分减去该物品的均值 # 注意只对非零元素操作,保持稀疏性 centered = item_user_matrix.copy().astype(float) rows, cols = centered.nonzero() centered[rows, cols] -= item_mean[rows] # 计算模长 norms = np.sqrt((centered.multiply(centered)).sum(axis=1)) norms[norms == 0] = 1e-9 # 防止除零 # 归一化后点积即为修正余弦相似度 normalized = centered.multiply(1.0 / norms) sim_matrix = normalized @ normalized.T return sim_matrix这段代码的关键在于保持稀疏性——只对非零元素做中心化,不要傻乎乎地把整个矩阵稠密化再操作。我见过有同学直接matrix - mean然后内存直接炸掉,就是因为忽略了这一点。
3.3 相似度归一化:一个容易被忽略的细节
算完相似度之后,还有一个步骤经常被跳过但影响很大:归一化。具体做法是把每个物品的相似度向量除以该向量的最大值,让相似度落在 [0, 1] 区间。
为什么要做这一步?因为不同物品的相似度分布范围不一样。有的物品和很多物品都高度相似,相似度最大值可能是 0.9;有的物品比较独特,最大值只有 0.3。如果不归一化,前者在加权求和时会天然占据更大权重,导致推荐结果被少数"社交广泛"的物品主导。
归一化之后,每个物品的相似度都被拉到同一个尺度,加权求和时更公平。这个技巧在 ItemCF 里几乎是标配,但在很多教程里被一笔带过,实际做的时候千万别漏。
4. 完整实操流程:从原始数据到推荐结果
4.1 数据准备与预处理
假设我们拿到一份用户行为日志,字段是 user_id、item_id、rating、timestamp。第一步不是急着算相似度,而是做几件脏活累活。
去重:同一个用户对同一个物品可能有多条记录(比如多次点击、多次购买),需要按时间取最新一条,或者做聚合。我一般按 (user_id, item_id) 分组取 timestamp 最大的那条。
过滤低频:交互次数少于 5 次的用户、被交互少于 5 次的物品,直接过滤掉。这些数据对相似度计算贡献极小,反而增加噪声和计算量。这个阈值不是拍脑袋定的,可以用分位数看分布——通常过滤掉后 10% 的长尾。
时间衰减:用户三个月前喜欢的物品和昨天喜欢的物品,权重不应该一样。常见做法是给评分乘一个衰减因子,比如 exp(-λ * Δt),λ 取 0.01 到 0.05 之间。这个参数需要根据业务节奏调,快消品衰减快,耐用品衰减慢。
构建矩阵:把清洗后的数据转成用户-物品评分矩阵。如果只有隐式反馈(点击、浏览),没有显式评分,就把交互次数或者是否交互作为值,通常用 0/1 或者 log(1+count) 表示。
4.2 UserCF 的完整计算流程
UserCF 的流程可以拆成五步:
- 构建用户-物品矩阵:行是用户,列是物品,值是评分。
- 计算用户相似度:用 Pearson 或余弦,得到用户相似度矩阵。
- 选取 Top-K 邻居:对每个用户,找出最相似的 K 个用户。K 一般取 20 到 100。
- 生成推荐:对目标用户未交互过的物品,用邻居的评分加权求和预测得分。
- 排序取 Top-N:按预测得分排序,取前 N 个推荐。
预测公式是:pred(u, i) = Σ(v∈N(u)) sim(u,v) * r(v,i) / Σ|sim(u,v)|。其中 N(u) 是 u 的邻居集合,r(v,i) 是邻居 v 对物品 i 的评分。
这里有个细节:加权求和时要不要减去用户均值?如果用的是 Pearson 相似度,最好减,因为 Pearson 本身已经中心化了,预测时保持一致。如果用的是余弦,可以不减。这个一致性很重要,混用会导致预测偏差。
4.3 ItemCF 的完整计算流程
ItemCF 的流程和 UserCF 对称,但有几个关键差异:
- 构建物品-用户矩阵:行是物品,列是用户。
- 计算物品相似度:用修正余弦,这是和 UserCF 最大的区别。
- 选取 Top-K 相似物品:对每个物品,找出最相似的 K 个。K 一般取 10 到 50,比 UserCF 小。
- 生成推荐:对用户交互过的物品,找出它们的相似物品,加权求和。
- 排序取 Top-N。
预测公式是:pred(u, i) = Σ(j∈S(i,K)∩I(u)) sim(i,j) * r(u,j)。其中 S(i,K) 是和 i 最相似的 K 个物品,I(u) 是用户 u 交互过的物品集合。
注意这里没有除以相似度之和,因为 ItemCF 的推荐理由通常是"因为你喜欢 A,所以推荐相似的 B",加权求和更符合直觉。如果除以归一化因子,反而会削弱强相似物品的贡献。
4.4 一个可复现的参数配置参考
我把实践中比较稳的一套参数列出来,你可以作为起点:
| 参数 | UserCF 推荐值 | ItemCF 推荐值 | 说明 |
|---|---|---|---|
| 相似度度量 | Pearson | 修正余弦 | 按场景可换 |
| 邻居数 K | 40 | 20 | 需网格搜索 |
| 相似度阈值 | 0.1 | 0.05 | 过滤弱相似 |
| 最小交互数 | 5 | 5 | 过滤长尾 |
| 推荐列表长度 N | 20 | 20 | 按业务定 |
| 时间衰减 λ | 0.02 | 0.02 | 快消品调大 |
这套参数不是万能的,但能保证你在大多数数据集上跑出一个"不丢人"的基线。调参的顺序建议是:先调 K,再调相似度阈值,最后调时间衰减。
5. 常见问题与排查技巧实录
5.1 推荐结果全是热门物品怎么办
这是新手最常遇到的问题。原因通常是热门物品的共现次数天然就多,相似度计算时被放大。解决办法有三个:
第一,对热门物品做惩罚。在相似度公式里除以物品流行度的对数,比如 sim / log(1 + popularity)。这个惩罚力度要适中,太强会伤害准确性。
第二,在推荐结果里做多样性重排。取 Top-100 候选,然后用 MMR(最大边际相关)算法重排,保证推荐列表里既有热门也有长尾。
第三,降低热门物品的评分权重。如果用的是隐式反馈,把热门物品的 0/1 值改成 IDF 权重,让冷门物品的交互更有信息量。
我实测下来,第一种和第二种组合使用效果最好。单纯靠惩罚流行度,容易把真正的好物品也压下去。
5.2 相似度矩阵太大算不动怎么办
物品数超过 10 万时,全量相似度矩阵会非常吃资源。工程上的处理策略:
- 分块计算:把物品分成若干块,块内算相似度,块间只算共现次数高的对。
- 倒排索引加速:对每个用户,只计算他交互过的物品之间的相似度,其余跳过。因为两个物品如果没有共同用户,相似度必然是 0。
- 近似算法:用 MinHash 或 SimHash 做局部敏感哈希,把相似度计算降到亚线性。
- 定期全量 + 增量更新:相似度矩阵每周全量重算一次,每天做增量更新。
这里有个经验:ItemCF 的相似度矩阵其实不需要特别精确。因为最终推荐是 Top-K 加权,少量相似度的误差对排序结果影响很小。所以用近似算法完全可接受,不必追求精确计算。
5.3 新用户和新物品怎么处理
新用户没有历史行为,UserCF 和 ItemCF 都无能为力。这时候需要降级策略:
- 新用户:推热门榜单、编辑精选、或者基于注册信息的规则推荐。
- 新物品:给一个初始曝光池,用探索-利用策略(如 Thompson Sampling)快速收集反馈。
- 混合方案:把协同过滤结果和内容推荐结果融合,新用户以内容推荐为主。
我踩过的一个坑是:新物品刚上线时,因为共现次数少,相似度算出来全是 0,导致永远得不到曝光。解决办法是给新物品一个"探索配额",强制在推荐列表里留 1 到 2 个位置给新物品,用实际反馈来积累共现数据。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 推荐全是热门 | 流行度偏差 | 看推荐物品的流行度分布 | 加流行度惩罚 + 多样性重排 |
| 相似度全为0 | 数据太稀疏 | 统计共现次数分布 | 降低最小交互阈值 |
| 预测分数异常高 | 除零或未归一化 | 检查分母和相似度范围 | 加平滑项 + 归一化 |
| 推荐结果不更新 | 缓存未失效 | 检查缓存更新策略 | 设置合理的 TTL |
| 离线指标好线上差 | 数据泄漏 | 检查训练测试划分 | 按时间划分而非随机划分 |
| 冷启动效果差 | 无历史行为 | 统计新用户占比 | 降级到热门或内容推荐 |
5.5 几个只有踩过才知道的坑
坑一:离线评估的数据泄漏。很多人做离线评估时随机划分训练测试集,这是错的。因为推荐系统有时间属性,用未来的数据预测过去,指标会虚高。正确做法是按时间划分,用前 80% 的数据训练,后 20% 测试。
坑二:相似度矩阵的对称性。理论上 sim(A,B) = sim(B,A),但工程实现时如果用了近似算法或者分块计算,可能不对称。使用前最好做一次对称化处理,取两者的平均或最大值。
坑三:评分预测和排序是两回事。协同过滤优化的是评分预测的 RMSE,但业务关心的是排序质量(NDCG、Hit Rate)。这两个目标不完全一致。实践中应该直接用排序指标来调参,而不是盯着 RMSE。
坑四:K 值不是越大越好。K 太大,会引入不相似的邻居,噪声增加;K 太小,覆盖不够,推荐结果单一。我一般从 20 开始,以 10 为步长往上试,找到指标拐点。
6. 效果评估与调优:怎么知道你的协同过滤跑得好不好
6.1 离线评估指标的选择
评估推荐系统不能只看一个指标。我通常同时看四个:
准确率类:Precision@K、Recall@K、NDCG@K。NDCG 最重要,因为它考虑了排序位置,越靠前的推荐权重越大。
覆盖率:推荐系统能覆盖多少比例的物品。覆盖率太低说明推荐结果集中,长尾物品得不到曝光。
多样性:推荐列表内部物品的差异程度。多样性太低,用户会觉得"怎么老是推这些"。
新颖性:推荐物品的平均流行度。新颖性高说明推荐了冷门但可能相关的物品。
这四个指标往往互相矛盾——准确率高的模型通常覆盖率低。所以调优的本质是在多个指标之间找平衡,而不是单点最优。
6.2 线上评估的注意事项
离线指标好不代表线上效果好,这是推荐系统的铁律。线上评估要注意:
- A/B 测试的样本量:太小容易得出错误结论,一般需要至少一周的数据。
- 核心指标的选择:点击率、转化率、停留时长、次日留存,不同业务关注点不同。
- 长期效果 vs 短期效果:有些策略短期点击率高,但长期会伤害用户信任。需要看留存指标。
我的经验是:离线指标提升 5% 以内,线上大概率看不出来。所以离线调优时,要追求显著提升,而不是纠结小数点后的差异。
6.3 一个实用的调优流程
我总结的调优流程是这样的:
- 先跑通基线,记录所有指标。
- 单独调 K 值,画指标曲线,找拐点。
- 单独调相似度阈值,观察覆盖率和准确率的权衡。
- 加入时间衰减,看指标变化。
- 尝试不同的相似度度量,对比效果。
- 做消融实验,确认每个改进的贡献。
每一步只改一个变量,这样才能知道是哪个改动带来的效果。我见过有人一次性改五个参数,结果指标涨了也不知道为什么涨,跌了也不知道为什么跌,这种调优是无效的。
7. 从协同过滤到混合推荐:下一步可以怎么走
基于邻域的协同过滤是一个很好的起点,但它不是终点。当你把 UserCF 和 ItemCF 都跑通、调优之后,下一步可以考虑几个方向。
方向一:融合 UserCF 和 ItemCF。两者各有优势,加权融合往往比单用好。融合权重可以用验证集学习,也可以简单取 0.5。我实测下来,融合后 NDCG 通常能提升 3% 到 8%。
方向二:加入内容特征。纯协同过滤在冷启动和稀疏场景下表现差,加入物品的类别、标签、文本描述等内容特征,能显著改善。这就是经典的混合推荐思路。
方向三:用矩阵分解做召回。协同过滤作为召回通道,矩阵分解作为另一路召回,最后用排序模型融合。这是工业界最常见的架构。
方向四:图神经网络。把用户-物品交互建成二部图,用 GNN 做消息传递,本质上是对协同过滤的深度化扩展。这个方向近几年的论文很多,但工程落地的复杂度也高。
我个人在实际操作中的体会是:不要急着上深度模型,先把协同过滤的基线做扎实。很多团队一上来就搞双塔、搞 DIN,结果连 ItemCF 的基线都没跑过,最后发现是数据处理出了问题。协同过滤的价值不只是它本身,更是它提供了一个简单、可解释、可调试的参照系。当你的复杂模型打不过这个参照系时,问题往往不在模型,而在数据或评估环节。
最后再分享一个小技巧:把 ItemCF 的相似物品列表导出来人工看一遍。你会发现很多有意思的现象——有些相似度很高的物品对,业务上完全不相关,这通常意味着数据里有噪声或者共现是偶然的。这种人工检查是发现数据问题最快的方式,比看任何指标都直观。