想象你刚搬进一个叫“知味小镇”的地方。这里街头巷尾藏着上百家餐厅,你既没熟人、也没攻略,最怕花了钱却吃到不合口味的菜。好在镇上有一款“食欲相通”App:你给几道菜打分后,它会帮你找到一群口味极其相似的“口味知己”,再根据他们常去的店来猜你也会喜欢什么。这个思路,就是推荐系统里大名鼎鼎的用户协同过滤(UserCF)。它不研究菜品配方,只研究“你身边的人和你有多像”。而判断两个人“口味像不像”的过程,叫做相似度计算——就像一把看不见的尺子,量出两个用户的兴趣距离。本文会用知味小镇的故事,把这把尺子的三种量法拆开,走一遍从评分矩阵到推荐结果的完整算法流程,再看看工程落地时常见的性能问题和坑,帮你从零建立起对 UserCF 的直觉。
掌握 UserCF 与相似度计算,是理解推荐系统“人以群分”本质的关键一步。对 AI 从业者来说,它是设计个性化推荐、用户增长和社交分发方案的底层能力,能让你从“只会调包”进阶到“能设计策略”。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
UserCF协同过滤基本原理 用户-物品评分矩阵 UserCF推荐流程 找口味相似的朋友 UserCF协同过滤可视化:展示用户相似度网络、邻居选择、TopN推荐生成及冷启动解决方案。
核心原理
假设你是知味小镇的新住户,打开“食欲相通”App,第一件事不是看店铺排行,而是回答“你喜欢火锅还是甜品”。App 发现,你和镇上的小美、老周口味高度一致,于是告诉你:“这两位是难得的口味知己,他们常去的店,你大概率也喜欢。”这个逻辑听起来像社交,但它正是 UserCF 的核心思想:物以类聚,人以群分。算法不再死盯物品内容,而是把每个用户变成一串行为特征,再根据行为相似度寻找“同类人”。
在推荐系统术语里,这些“口味知己”叫做最近邻用户。UserCF 的第一步是构造用户-物品评分矩阵:每行是一个用户,每列是一个物品,单元格是用户对物品的打分或交互强度。矩阵本身不关心物品是火锅还是甜品,只看用户行为是否一致。第二步是计算用户两两之间的兴趣相似度,取出和当前用户最像的 K 个用户。第三步把这些邻居喜好的物品聚合起来,按分数排序,生成推荐清单。
如果把 UserCF 看成一场小镇聚餐,那么评分矩阵是“每个人对每道菜的评价记录”,相似度计算是“判断谁的口味和你同频”,最终推荐就是“餐桌上大多数人都说好,而你还没尝过的那几道菜”。理解了这三个角色,你就能抓住 UserCF 的骨架:先找相似的人,再借用他们的眼光完成推荐。
相似度计算
相似度计算是 UserCF 的“口味尺子”。怎么判断两位食客口味像不像?最朴素的想法是:看他俩共同去过的店占所有去过的店的比例。这就是 Jaccard 相似度,它只关心“共同评分过哪些物品”,不关心打了几分。
Jaccard 简单,但它忽略了一个信息:即使两个人去过同一家店,一个给 5 星、一个给 1 星,口味其实完全相反。因此更常用的是余弦相似度,它把每个用户对一组物品的评分看成多维空间里的一个向量,向量指向越接近,口味越一致。
余弦相似度还有个小麻烦:知味小镇里有“高分党”,见什么都给 4 星以上;也有“严苛党”,最高只给 3 星。如果直接比较原始评分,两个人的“手劲”不同就会干扰判断。于是我们需要皮尔逊相关系数:先把每个用户的评分减去自己的平均分,再计算余弦相似度,这样就能去掉个人评分习惯的偏移。
三种尺子里,Jaccard 适合行为只有“买/没买”的场景,余弦相似度适合评分数据,皮尔逊则更适合评分习惯差异明显的社区。选对尺子,推荐系统的“口味知己”才找得准。
- 分子去均值后的协动关系:两人各自减去自己的平均分后再相乘累加,正分表示两人都超出各自常规水平。
- 分母去均值后的向量长度:用去均值后的评分波动范围代表每个用户的口味标准差。
- 整体含义把“给分手劲”校准到同一水平后,再判断两人口味是否同涨同跌。
算法流程
知味小镇的“食欲相通”App 背后,是一条像做菜一样清晰的流水线。我们可以把它拆成四步:准备食材、称量相似度、挑选同好、下锅调味。准备食材就是构建用户-物品评分矩阵;称量相似度就是计算用户两两之间的相似度,得到相似度矩阵;挑选同好是给目标用户选出相似度最高的 K 个邻居;下锅调味则是把邻居的评分加权汇总,预测目标用户对未评分物品的分数,最后排序生成 TopN 推荐。
在预测阶段,不能简单地把邻居的原始评分直接平均。因为有人偏爱打高分,有人习惯打低分,加权平均前需要先把每位用户的评分减去自己的平均分做“口味校准”,再按相似度加权,最后加回目标用户的平均分。这就是经典的预测评分公式:
最终得到预测分后,把目标用户没打过分的物品按预测分从高到低排序,去掉重复与已购,截取前 N 个,就是推荐列表。整个过程像一次小镇美食推荐:几个口味知己给出的每道菜评价,先折算成“比他们日常水平高多少”,再按亲密程度加权,最后加上你自己的基准分,得到最适合你的新尝试。
- 基准分$\bar{r}_u$ 是目标用户自己的历史平均评分,用来保底。
- 邻居贡献的差值邻居 v 对物品 i 的评分减去邻居自己的平均分,代表“这道菜对 v 来说比日常偏好高多少”。
- 相似度归一化除以相似度绝对值之和,让相似度更高的邻居拥有更大话语权。
工程实现
当知味小镇从几十人扩张到几十万人,靠手工记菜谱就不现实了。工程实现要回答三个问题:数据存哪里、相似度何时算、推荐请求多快返回。首先,用户-物品矩阵极其稀疏,因为一个人不可能吃过镇上所有餐厅,更不可能给所有物品打过评分。直接用二维数组会浪费内存,一般用稀疏矩阵、键值对或分布式表来存储用户行为。
相似度计算是重活,通常放在离线阶段完成。系统定期(例如每天)扫描用户行为,计算用户两两相似度,把结果写入索引服务。到了线上,用户打开 App 时只需读取已算好的相似用户,再聚合候选物品并排序,延迟可以控制在几十毫秒内。这种“离线算邻居、在线做聚合”的架构是 UserCF 工程落地的主流方式。
数据更新也是工程重点:用户兴趣会变,每天都有新评分进来。如果每次都全量重算相似度,成本太高。常见做法是记录增量行为,周期性地对受影响用户做局部更新,再合并进在线索引。工程实现就像小镇餐厅每天打烊后统一统计“今天谁和谁吃了同一道菜并打了近似的分”,白天营业时直接用昨晚的结果服务客人,既省力又及时。
性能调优
UserCF 最大的性能瓶颈在于:如果用户数为 N,两两计算相似度是 O(N²) 级别。知味小镇从一万用户涨到一百万,直接计算所有用户对会让服务器不堪重负。常见的优化策略是倒排索引:先把“用户→评分过的物品”反转成“物品→评分过的用户”,这样只需要对出现在同一个物品上的用户对计算相似度,就能过滤掉大量完全没交集的用户对。
第二招是相似度缓存。用户的兴趣不会分钟级剧变,因此相似度结果不必实时重算。系统可以把“用户→最近邻”写入缓存,设置过期时间,只有缓存失效时才重新拉取。第三招是近似最近邻。当数据规模极大时,可以放弃精确 TopK,改用局部敏感哈希(LSH)或向量检索库,在召回阶段先圈定一个候选池,再在候选池里精确排序,用少量精度换取数量级的速度提升。
对于兴趣变化,还可以设计增量收敛:新评分先进入在线日志,离线任务每小时做一次小批量更新,而不是每天全量重建。性能调优的本质,是把“所有用户两两比较”的笨办法,变成“先大幅缩小候选集、再精准计算”的分层策略。就像餐厅不做“全镇每两个人比口味”,而是先看哪些人常在同一家店出现,再深挖他们的口味曲线。
评估指标
推荐做得好不好,不能只靠感觉说“好像挺准”。知味小镇想验证“口味知己推荐”是否靠谱,可以办一场试吃盲评。第一个指标是准确率:推荐清单里有多少物品是用户真正喜欢的;它回答“推了的东西准不准”。第二个指标是召回率:用户真正喜欢的所有物品中,有多少被系统成功捞了回来;它回答“该给的有没有漏掉”。两者往往此消彼长,需要一起观察。
除了命中情况,还要关注推荐列表的覆盖率和多样性。如果系统只推荐最火爆的几家店,准确率也许很高,但小镇冷门好店永远没人发现,用户也容易审美疲劳。覆盖率衡量推荐结果覆盖了多少物品,多样性衡量推荐结果是否足够分散。UserCF 天然容易因为相似用户扎堆而把结果局限在热门物品上,因此工程上常常在排序阶段加入多样性惩罚或品类配额。
评估指标是整个推荐系统的仪表盘。准确率和召回率决定“口味知己”靠不靠谱,覆盖率和多样性决定推荐生态健不健康。上线新算法时,团队通常会做离线评估、小流量 AB 实验,再逐步扩大范围,确保每个指标都在预期之内。
常见挑战
UserCF 并不是万能的,它最怕的第一件事就是冷启动。一个新搬进小镇的用户没有任何打分记录,系统找不到他的“口味知己”,相似度全是零。此时可以用热门物品兜底,或结合用户注册时填写的口味偏好做粗粒度匹配,等积累一定行为后再切换到 UserCF。
第二个挑战是数据稀疏。用户评过的物品往往只占全量物品的极小比例,导致余弦和皮尔逊计算的共同维度很少,相似度估计不稳定。缓解手段包括降低相似度计算维度、使用隐式反馈(浏览、收藏、停留时长)来补充信号,以及引入物品侧信息作为桥梁。
第三个挑战是扩展瓶颈与兴趣漂移。随着用户规模增长,相似度矩阵会爆炸式膨胀;同时用户口味会随时间改变,三个月前的“口味知己”现在可能已经不再同频。解决思路是用增量更新、时间衰减权重和近似索引来控制成本。冷启动、稀疏、扩展、漂移,这四座大山决定了 UserCF 不是所有场景的最优解,但也正是它们,推动着工程师不断做出更精致的取舍。
应用场景
在知味小镇之外,UserCF 的身影随处可见。电商平台里,系统通过用户的购买、加购行为计算相似度,向一位买家推荐“和你品味相似的人还买了什么”。这种策略在商品数量庞大、用户行为丰富的场景下,能有效帮助用户发现新品类,适合私域电商和垂直社区。
内容社区是 UserCF 的另一块沃土。新闻资讯、短视频、音乐电台都强调“实时兴趣”,用户今天想看的和昨天可能完全不同。UserCF 能快速捕捉相似用户当前的热门行为,把新热点扩散给同一人群,比单纯依赖内容关键词更灵活。例如音乐 App 会根据你和某位陌生乐迷的收藏重合度,向你推荐他的歌单。
社交产品则直接借用相似度来做关系推荐:系统发现你和某群用户对同样的内容点赞、关注同样的人,于是把这些人推荐为“可能感兴趣的人”,让新用户更快找到归属感。在真实案例中,推荐系统会组合多种策略:冷启动阶段用热门与物品协同过滤兜底,用户行为丰富后再叠加 UserCF;这样既保留了个性化,也规避了新用户的冷启动困境。知味小镇的口味知己,就这样演变成了互联网世界里无处不在的“兴趣纽带”。
AI 应用场景
资讯App“猜你喜欢”
阶段:召回与个性化排序
解决的问题:传统资讯推荐依赖人工编辑或内容分类,无法捕捉用户当下最关心的话题。UserCF 通过相似用户的行为扩散热点,让新内容快速找到感兴趣的人群。
具体案例:某新闻客户端对用户 5 分钟阅读行为建模,计算用户之间的余弦相似度,找到 Top50 相似用户,再聚合他们正在热读但当前用户未读的文章。新用户先用热门文章兜底,积累行为后平滑切换到 UserCF。
电商平台“相似买家还买了”
阶段:离线召回策略
解决的问题:用户购买行为稀疏,单纯基于商品的关联规则难以覆盖冷门品类。UserCF 能通过“相似买家”的购买历史,为用户推荐新品类。
具体案例:某私域电商平台只有几千个 SKU,团队用购买记录构造 0/1 矩阵,用 Jaccard 相似度找出“口味一致的买家群”,推荐他们买过但当前用户未购买的品类,覆盖率和客单价都有提升。
社交产品“可能感兴趣的人”
阶段:用户关系挖掘
解决的问题:新用户加入社区后不知道关注谁,冷启动体验差。UserCF 在“兴趣内容”层面找到与用户口味一致的人,让关系推荐更有温度。
具体案例:某音乐社区比较用户在收藏歌曲上的评分向量,计算余弦相似度后,将相似度高的用户推荐为“口味知己”,用户可以一键关注并共享歌单。
Python 演示
# -*- coding: utf-8 -*-# 极简 UserCF: 构建矩阵, 计算余弦相似度, 预测评分并生成 TopN 推荐import math class UserCF: def __init__(self, ratings): self.ratings = dict(ratings) self.users = sorted(self.ratings.keys()) self.items = sorted({item for u in self.users for item in self.ratings[u]}) self.neighbors = {} def score(self, user, item): return self.ratings.get(user, {}).get(item, 0.0) def cosine(self, u, v): common = [i for i in self.items if self.score(u, i) and self.score(v, i)] if not common: return 0.0 dot = sum(self.score(u, i) * self.score(v, i) for i in common) nu = math.sqrt(sum(self.score(u, i) ** 2 for i in common)) nv = math.sqrt(sum(self.score(v, i) ** 2 for i in common)) return dot / (nu * nv) if nu and nv else 0.0 def build_similarity(self): for idx, u in enumerate(self.users): for v in self.users[idx + 1:]: s = self.cosine(u, v) if s > 0: self.neighbors[(u, v)] = s self.neighbors[(v, u)] = s def predict(self, user, item): if self.score(user, item) > 0: return self.score(user, item) total = 0.0 weight = 0.0 for v in self.users: if v == user or self.score(v, item) == 0: continue sim = self.neighbors.get((user, v), 0.0) if sim <= 0: continue total += sim * self.score(v, item) weight += sim return total / weight if weight else 0.0 def recommend(self, user, top_n=3): preds = [(item, self.predict(user, item)) for item in self.items if self.score(user, item) == 0] preds.sort(key=lambda x: x[1], reverse=True) return preds[:top_n] if __name__ == '__main__': ratings = { '小明': {'火锅': 5, '甜品': 4, '烧烤': 1}, '小红': {'火锅': 4, '甜品': 5, '面馆': 4}, '阿强': {'烧烤': 5, '火锅': 2, '酒吧': 4}, '小美': {'甜品': 3, '面馆': 5, '火锅': 4}, '你': {'火锅': 5, '甜品': 3}, } cf = UserCF(ratings) cf.build_similarity() print('用户相似度:') for pair, s in sorted(cf.neighbors.items()): if pair[0] < pair[1]: print(pair, round(s, 4)) print('给小明的预测:') for item, p in cf.recommend('小明'): print(item, round(p, 4))
self.items = sorted({item for u in self.users for item in self.ratings[u]})从所有用户评分中提取全部物品,构建统一物品轴。common = [i for i in self.items if self.score(u, i) and self.score(v, i)]只保留两个用户都有评分的维度,过滤稀疏噪声。dot = sum(self.score(u, i) * self.score(v, i) for i in common)计算向量点积,方向一致的评分贡献为正。return dot / (nu * nv) if nu and nv else 0.0除以向量长度后得到余弦相似度,防除零。for idx, u in enumerate(self.users)只对u < v的用户对计算一次,同时回填两个方向。if self.score(user, item) > 0: return self.score(user, item)已评分的物品直接返回真实分,不再预测。total += sim * self.score(v, item)用相似度做权重,累加邻居评分。return total / weight if weight else 0.0除以权重总和得到最终预测分。preds.sort(key=lambda x: x[1], reverse=True)按预测分从高到低排序,完成 TopN 截取。
常见误区
误区既然都是协同过滤,用户协同和物品协同应该差不多,反正都是“看了又看”这一套。
正解两者方向恰好相反。UserCF 是“人以群分”,先找相似用户,再用用户偏好推荐物品;适合用户少、兴趣变化快的场景。ItemCF 是“物以类聚”,先找相似物品,再推荐用户没有看过但相似的物品;适合物品数远小于用户数、内容相对稳定的场景。把两者混为一谈,会导致策略选型错误。
为何常见:都叫“协同过滤”,都用到“相似度”,初学者容易只记结论不记方向。
误区直接拿用户原始评分算余弦就行,反正都是看方向。
正解“高分党”和“低分党”会给所有物品系统性偏移。例如 A 只打 4-5 分,B 只打 1-3 分,即使两人偏好排序完全一致,原始余弦分数也会偏低。皮尔逊相关系数先减均值再算余弦,就是为了消除这种评分尺度偏差。工程上根据数据分布决定是否中心化,而不是默认不做。
为何常见:评分看起来是绝对数值,大家容易忽略用户之间的“手劲”不同。
误区把相似用户全用上,信息量大,推荐肯定更全面、更准确。
正解邻居数量过大会引入噪声。相似度很低的用户可能只有一两个共同物品,评分模式并不可靠,强行纳入会稀释真正“口味知己”的贡献。实际操作中通常用 TopK 或相似度阈值双重限制,再通过离线评估确定 K 值。
为何常见:直觉上“数据越多越好”,忽略了低质量相似度带来的干扰。
误区用户数翻倍,最多就是多等一会儿,服务器总能算完。
正解两两相似度计算是 O(N²) 复杂度。10 万用户就是 50 亿对,内存和时间都会爆炸。工业实现必须依赖倒排索引缩小候选、近似最近邻减少计算量、离线缓存避免重复计算,否则系统无法扩展。
为何常见:小数据集上跑通过,容易误以为直接放大没有问题。
面试题
Q1. UserCF 中“CF”的全称是?
- A.Collaborative Filtering← 正确答案
- B.Content Filtering
- C.Collaborative Feature
- D.Clustered Filtering
答案:A。CF 是 Collaborative Filtering 的缩写,即协同过滤。它依靠群体行为而非物品内容做推荐,是 UserCF 和 ItemCF 的共同基础。
Q2. UserCF 的第一步通常是?
- A.构建用户物品评分矩阵← 正确答案
- B.训练深度神经网络
- C.收集物品文本描述
- D.计算全局热门榜单
答案:A。UserCF 需要把用户行为整理成用户-物品评分矩阵,才能进一步计算用户之间的相似度。神经网络和物品文本不是必需输入,热门榜单是兜底策略而非第一步。
Q3. 下列哪项属于 UserCF 的相似度指标?
- A.Jaccard 相似度← 正确答案
- B.信息熵增益
- C.梯度下降损失
- D.正则化系数
答案:A。Jaccard 相似度通过交集除以并集衡量两个用户共同评分的占比,是 UserCF 可用的相似度指标之一。其余三项属于信息论与优化领域概念。
Q4. 余弦相似度计算的是两个用户评分向量的?
- A.两个向量的夹角余弦← 正确答案
- B.欧氏距离的平方
- C.标准差的乘积
- D.曼哈顿距离之和
答案:A。余弦相似度的几何意思是两个向量之间的夹角余弦值。夹角越小越接近 1,向量方向越一致;它关注方向而非距离长短。
Q5. 用户“高分党”和“低分党”评分尺度不同,最有效的缓解方式是?
- A.先减去用户评分均值← 正确答案
- B.把所有评分强制置一
- C.直接使用原始评分
- D.只保留最高评分项
答案:A。减去用户自身评分均值能消除个人打分习惯带来的偏移,这正是皮尔逊相关系数的核心思想。强制置一会丢失信息,直接使用原始评分无法处理尺度差异。
Q6. UserCF 中“TopN 邻居”的含义是?
- A.前K个最相似的用户← 正确答案
- B.全部用户作为邻居
- C.随机抽取K个用户
- D.只留一个相似用户
答案:A。UserCF 会为每个用户选取相似度最高的前 K 个用户作为邻居,用来聚合推荐候选。邻居太少会缺少信息,太多会引入噪声。
Q7. 以下属于应对数据稀疏和规模增长的工程手段有?
- A.稀疏矩阵与分布式存储← 正确答案
- B.倒排索引加速候选← 正确答案
- C.全量两两暴力计算
- D.近似最近邻检索← 正确答案
答案:ABD。稀疏存储降低内存,倒排索引通过“物品→用户”反向映射缩小候选对,近似最近邻降低精确 TopK 计算成本。全量两两暴力计算正是需要避免的做法。
Q8. UserCF 相似度矩阵空间复杂度最直接的决定因素是?
- A.用户数量平方级增长← 正确答案
- B.物品数量平方级增长
- C.评分分值大小变化
- D.最近邻个数平方变化
答案:A。UserCF 需要保存用户两两相似度,所以空间复杂度随用户数 N 呈 O(N²) 增长。物品数量增加只影响评分矩阵列数,不影响用户对数量。
Q9. 缓解 UserCF 冷启动问题可用的策略有?
- A.新用户热门物品兜底← 正确答案
- B.用户画像特征冷启动← 正确答案
- C.直接放弃无评分用户
- D.物品协同过滤兜底← 正确答案
答案:ABD。新用户没有评分时,可以用热门物品兜底,或基于注册画像做粗匹配,也可以用 ItemCF 从当前浏览物品出发推荐相似物品。放弃无评分用户会损失新用户流量,不可取。
Q10. 关于皮尔逊相关系数与余弦相似度的关系,说法正确的有?
- A.皮尔逊是中心化后的余弦← 正确答案
- B.皮尔逊消除评分尺度偏差← 正确答案
- C.均值相同则完全等价
- D.皮尔逊必定大于余弦值
答案:AB。皮尔逊相关系数等价于“先把每个用户评分减去自身均值,再计算余弦相似度”,因此能消除评分尺度偏差。即使两人均值相同,各维度偏移不同,也不会等价;皮尔逊与余弦的大小也没有必然关系。