资讯动态

协同过滤从原理到实战:手把手实现推荐引擎

发布时间:2026/10/20 7:06:39 来源:尧图企业网站定制
入行做推荐系统这几年我被问得最多的一个问题就是推荐算法这么多到底该从哪里开始学我的答案一直没变过——先把协同过滤吃透。这个词听起来像个拗口的专业术语背后的逻辑其实特别朴素你不是一个人在看内容、买东西你的选择会被一大批跟你口味相似的人影响。协同过滤不需要内容标签不去分析“这部作品是科幻还是爱情”也不管商品是红是蓝它只看用户行为——你点过什么、欸过什么、买过什么就足以推断你接下来会对什么感兴趣。正因为它不依赖内容理解门槛低、见效快才会在各类推荐系统里沿用这么多年。这篇文章想把“原理 → 数学 → 代码 → 工程”这条线一次性讲清楚从 UserCF、ItemCF 的核心思想到相似度计算和评分预测背后的数学细节再用一份轻量的 Python 实现跑通一个迷你推荐引擎最后把我在实际项目里踩过的坑和排查方法摊开说。适合刚入门推荐算法的人、需要快速搭推荐功能的工程师以及想知道“推荐结果为什么是它”的产品同学。1. 协同过滤的整体思路为什么它仍是推荐系统的地基1.1 一个朴素的道理物以类聚人以群分想象一个场景你和几个朋友口味特别接近某天其中一个人说最近看了某部作品还不错你大概率会点开看看反过来如果另一个朋友跟你喜好完全相反他推的东西你可能根本不碰。协同过滤就是把这个生活经验抽象成了算法。所谓“协同”是汇聚大量用户的行为数据所谓“过滤”是从中筛出你最可能喜欢的内容。它和基于内容的推荐有个根本区别后者需要先理解内容本身比如维护一套类目体系、打标签、做关键词提取而协同过滤什么都不用理解只要有“谁对什么产生了反馈”这张行为表算法就能自己学出哪些用户是近邻、哪些物品彼此像。这个特性在工程上极其重要。内容标签体系需要人工持续维护冷门内容往往没人打标新上架的内容标签也不完整而用户行为数据是持续产生的只要埋点稳定协同过滤就能持续工作。这也是很多中小型产品第一次上线推荐功能时优先选择协同过滤的原因。1.2 两条经典路线UserCF 与 ItemCF协同过滤按计算方向分成两类。基于用户的协同过滤UserCF先算用户之间的相似度找到和你口味最像的一群人把你没看过但他们评价高的东西推荐给你。基于物品的协同过滤ItemCF则反过来先算物品之间的相似度把你以前喜欢过的物品的“孪生兄弟”找出来。对比项UserCFItemCF核心逻辑找相似用户推相似用户喜欢的找相似物品推你喜欢物品的相似品计算对象用户-用户相似度矩阵物品-物品相似度矩阵典型场景新闻、资讯、社区内容电商、视频、音乐实时性用户产生新行为后要重新算邻居关系用户点击一个物品可立刻找到相似物品冷启动短板新用户没有行为时难搞新物品没有行为时难搞可解释性“和你口味相近的用户正在看它”“因为你喜欢过某物所以推荐类似的它”实际项目里ItemCF 被用得更多。原因主要有两个第一物品数量通常比用户数量少一个量级物品相似度矩阵的存储和计算成本更低第二物品之间的“像不像”比用户之间的“像不像”稳定得多。用户的兴趣会漂移今天爱看游戏明天迷上烹饪但《某科幻电影》和《某科幻剧》之间的相似关系不会一夜之间改变。所以物品相似度很适合离线算好、线上直接查表实时性优势很明显。1.3 从矩阵分解到隐因子协同过滤的进阶形态基于近邻的 UserCF、ItemCF 虽然直观但有个天然短板依赖“直接可见”的相似关系。一旦用户和物品之间只有寥寥几次交互近邻就找不准了。矩阵分解的思路是把用户和物品各自投影到一个低维向量空间用向量的点积预测偏好程度。你可以把它理解为一种“隐性画像”我们不给任何真实标签但假设存在若干看不见的维度比如作品的“动作浓度”“剧情厚度”“文艺倾向”用户在每一个维度上有个隐含偏好值。矩阵分解就是通过大量行为数据把这些未知的向量硬学出来。原来的用户-物品评分矩阵 R被分解成两个小矩阵 P 和 QR ≈ P × Q^T训练时不断调整 P、Q 让预测误差变小。矩阵分解的优点在于泛化能力它不需要共同评分用户很多也能给出合理预测因为隐向量是从全量数据里学出来的而不是只看局部交集。缺点是解释性差、超参数多、训练成本高。但在工业界它几乎成了协同过滤类算法的主流形态。2. 核心细节拆解相似度计算与评分预测的数学原理2.1 三种相似度计算选谁更合适相似度是协同过滤的地基选错了后面全歪。最常用的有三种余弦相似度、皮尔逊相关系数、Jaccard 相似度。余弦相似度衡量的是两个向量在方向上的接近程度公式长这样cos(u, v) Σ(r_ui × r_vi) / ( √Σ(r_ui²) × √Σ(r_vi²) )它的好处是天然把“不同用户的评分尺度”做了归一化。假设用户 A 习惯给 4~5 分用户 B 习惯给 2~3 分如果用原始分值算距离他们的相似度会被整体拉低但余弦只看方向不看大小两个用户评分“高低起伏的形状”一致就能算出高相似度。皮尔逊相关系数是余弦的升级版。它在计算前先让每个用户减去自己的评分均值公式相当于在“中心化后的向量”上做余弦pearson(u, v) Σ((r_ui - μu) × (r_vi - μv)) / ( √Σ(r_ui - μu)² × √Σ(r_vi - μv)² )减均值这一步非常实用。因为每个用户的习惯不同有人手松看什么都是五星有人手紧神作也只给四星。皮尔逊先去掉这个“整体出手松紧”的偏差只看相对好恶所以在评分制数据集上通常比纯余弦更准。Jaccard 相似度不看具体分数只看是否有行为jaccard(u, v) |Iu ∩ Iv| / |Iu ∪ Iv|它适合 0/1 行为数据比如“是否收藏”“是否点击”“是否购买”这种没有评分的隐式反馈。实际业务里很多产品根本没有评分机制只有行为记录Jaccard 就派上了用场。三种方法没有绝对优劣取决于数据长什么样。我个人的经验是评分数据完整时优先皮尔逊行为数据只有“有没有”时用 Jaccard线上系统追求计算效率时可以先在稀疏矩阵上用余弦打底。注意两个用户只有一两个共同评分时皮尔逊和余弦都容易算出接近 1 或 -1 的极端值。这不是因为他们真的相似而是样本太少。数据越稀疏这个现象越严重。实操中一定要做惩罚比如共同评分不足 5 个的直接判为 0或者用收缩系数把相似度往 0 方向拉。2.2 评分预测公式直接平均还是均值偏移相似度算出来后下一步是预测目标用户对未接触物品的评分。最简单的办法是直接加权平均r_hat(u, i) Σ(sim(u, v) × r_vi) / Σ(sim(u, v))这个公式好理解但有个隐患它没有处理用户打分的尺度差异。如果某个邻居是“高分党”他随手给的 4 分可能相当于别人的 3 分直接加权会把预测结果整体带偏。更稳的做法是均值偏移版r_hat(u, i) μu Σ(sim(u, v) × (r_vi - μv)) / Σ(|sim(u, v)|)先看邻居 v 对这个物品的评分比他自己的平均分高多少、低多少把这个“相对偏差”加权汇总再加回目标用户自己的平均分。相当于先把每个用户拉到同一水平线上比较差异预测值就不会被某个人的打分习惯绑架了。我几乎在所有基于评分的项目里都用这个公式效果比朴素加权明显稳定。ItemCF 的预测公式类似只是把“用户邻居”换成“物品邻居”r_hat(u, i) Σ(sim(i, j) × r_uj) / Σ(sim(i, j))因为同一个用户的评分尺度是统一的按理说直接加权也能用但物品本身也有“热门度差异”有些物品基础分就高所以工程上我也会对物品评分做一个均值中心化处理效果更均匀。2.3 三个关键参数的调优心得第一个参数是邻居数量 K。K 太小时参与投票的邻居少结果方差大一个异常用户就能把预测带偏K 太大时边缘相似的用户也被拉进来相当于把“口味相似”稀释成了“口味有点沾边”。常见的范围是 20~50但具体数值取决于数据规模。数据量大可以适当放宽数据量小宁可选小 K。第二个参数是相似度阈值。设一个下限比如皮尔逊相关系数小于 0.3 的邻居直接不参与预测。这个阈值能过滤掉大量“看似相关、实际弱相关”的噪音。调阈值时我习惯先看相似度分布如果大多数值集中在 0~0.1那阈值就不宜设太高否则可用的邻居太少。第三个参数是评分归一化。除了均值中心化有时还需要对整个评分矩阵做尺度缩放把 1~5 分映射到 0~1 区间。这样做的目的是让多个来源的数据可以叠加。归一化没有统一答案核心原则是让不同评分习惯的用户处在同一可比空间里然后再做相似度计算。这几个参数都很敏感换一个数据集往往就要重新调。建议做一个小型的参数敏感性实验固定其他参数只动其中一个画一条效果曲线比凭感觉拍脑袋靠谱得多。3. 实战案例手写一个基于协同过滤的迷你推荐引擎3.1 数据集与工具准备为了把每一步都看得清清楚楚这里不用现成的大数据集而是造一份极简的模拟观影评分数据只有 5 个用户、6 部作品。目标是给用户 U5 推荐没看过的内容。数据里的数字是 1~5 的评分0 表示未评分ratings { U1: {M1: 4, M2: 3, M4: 5, M5: 5, M6: 2}, U2: {M1: 5, M2: 1, M3: 3, M4: 2, M5: 4}, U3: {M1: 2, M3: 4, M4: 4, M5: 3, M6: 1}, U4: {M2: 5, M3: 3, M4: 5, M5: 4, M6: 1}, U5: {M1: 4, M3: 4, M4: 5, M5: 4}, }U5 看过 M1、M3、M4、M5没有评过 M2 和 M6。推荐引擎要预测 U5 对 M2、M6 的打分然后按分数排序给出一条推荐列表。整个实现只用到 Python 标准库中的math没有任何第三方依赖复制即跑。3.2 UserCF 从零实现寻找邻居、预测评分、生成推荐第一步是计算用户相似度矩阵。这里用皮尔逊相关系数并在共同评分样本太少时直接返回 0防止小样本干扰import math all_users list(ratings.keys()) def get_common_items(u, v): return set(ratings[u]) set(ratings[v]) def pearson(u, v): common list(get_common_items(u, v)) if len(common) 2: return 0.0 mu sum(ratings[u][i] for i in common) / len(common) mv sum(ratings[v][i] for i in common) / len(common) num sum((ratings[u][i] - mu) * (ratings[v][i] - mv) for i in common) den math.sqrt( sum((ratings[u][i] - mu) ** 2 for i in common) * sum((ratings[v][i] - mv) ** 2 for i in common) ) return num / den if den ! 0 else 0.0 sim_matrix { u: {v: pearson(u, v) for v in all_users if v ! u} for u in all_users }第二步是预测。找出对目标物品有过评分、且与目标用户最相似的 K 个邻居然后按均值偏移公式打分def predict(u, item, k2): mean_u sum(ratings[u].values()) / len(ratings[u]) candidates [ (sim_matrix[u][v], v) for v in sim_matrix[u] if item in ratings[v] ] sims sorted(candidates, keylambda x: x[0], reverseTrue)[:k] if not sims: return mean_u num 0.0 den 0.0 for s, v in sims: mean_v sum(ratings[v].values()) / len(ratings[v]) num s * (ratings[v][item] - mean_v) den abs(s) return mean_u num / den if den ! 0 else mean_u for item in [M2, M6]: print(item, predict(U5, item))这段逻辑的核心是“先找到值得信任的邻居再让他们给目标内容投票”。排序后预测分高的物品进入推荐列表。实际运行结果里U5 对 M2 的预测分明显高于 M6M2 会排到候选列表第一位。这个结果符合直觉和 U5 最像的用户普遍给 M2 高分而 M6 在多数用户那里都拿到了低分。有一个很容易踩的坑隐藏在代码里如果某个邻居对目标物品根本没有评分那他在这一步就被过滤掉了不管相似度多高都没用。所以近邻集合 K 是在“有评分的候选者”里取 Top K而不是全局最近邻。很多初学版本没做这一步过滤结果推荐列表中总出现“莫名其妙的冷门品”。3.3 ItemCF 实现与结果对照ItemCF 的逻辑几乎是对称的把“用户之间的相似度”换成“物品之间的相似度”。下面这段代码用余弦相似度计算物品关系并预测 U5 对 M2 的评分def item_cos(i, j): common [u for u in all_users if i in ratings[u] and j in ratings[u]] if len(common) 0: return 0.0 num sum(ratings[u][i] * ratings[u][j] for u in common) den math.sqrt(sum(ratings[u][i] ** 2 for u in common)) * \ math.sqrt(sum(ratings[u][j] ** 2 for u in common)) return num / den if den ! 0 else 0.0 item_sim {i: {j: item_cos(i, j) for j in {M1, M2, M3, M4, M5, M6} if j ! i} for i in ...} def predict_item(u, item, k2): seen_items list(ratings[u].keys()) candidates [(item_sim[item][j], j) for j in seen_items if item ! j] sims sorted(candidates, keylambda x: x[0], reverseTrue)[:k] num sum(s * ratings[u][j] for s, j in sims) den sum(abs(s) for s, j in sims) return num / den if den ! 0 else 0.0在这个数据上M2 和 U5 已经看过的 M4、M5 行为模式很接近所以 ItemCF 也会把 M2 推到推荐前列。两条路线不约而同指向同一个结果这就是一种交叉验证。如果 UserCF 和 ItemCF 的推荐结果差异很大通常意味着数据太稀疏或者碰到了冷门物品这时不要急于上线先把数据质量梳理一下。从工程效率来看ItemCF 的在线计算路径极短用户产生一个行为系统查一下物品相似表把相似物品里用户没看过的按相似度排序即可。这比 UserCF 实时去更新用户邻居要快得多。3.4 用矩阵分解再进一步解决稀疏问题的思路近邻方法在小规模数据上很直观但真实系统里的用户-物品矩阵通常 99% 都是空的近邻几乎找不到几个有共同评分的用户。矩阵分解是破局思路之一。核心训练逻辑一句话随机初始化用户向量和物品向量然后沿着梯度方向不断修正让“真实评分”和“预测评分”的差距越来越小。import numpy as np # 构建评分矩阵 Rshape (用户数, 物品数)0 表示缺失 R np.array([ [4, 3, 0, 5, 5, 2], [5, 1, 3, 2, 4, 0], [2, 0, 4, 4, 3, 1], [0, 5, 3, 5, 4, 1], [4, 0, 4, 5, 4, 0], ]) K 3 lr 0.01 lam 0.02 P np.random.normal(0, 0.1, (5, K)) Q np.random.normal(0, 0.1, (6, K)) for _ in range(500): for u in range(R.shape[0]): for i in range(R.shape[1]): if R[u][i] 0: continue err R[u][i] - np.dot(P[u], Q[i]) P[u] lr * (err * Q[i] - lam * P[u]) Q[i] lr * (err * P[u] - lam * Q[i])这段代码里有两个容易被忽略的细节。一是只在非零评分上更新缺失值不参与训练二是每个更新步骤里都带了lam * P[u]这一项作用是正则化防止隐向量为了拟合一个样本长出异常大的数值。矩阵分解的核心是把“用户和他的邻居之间没有直接交集”的问题转化为“用户向量和物品向量是否能匹配”的问题从而在极稀疏的数据上也具备一定的表达能力。4. 生产中常见的坑与排查技巧实录4.1 冷启动新用户和新物品怎么推协同过滤是“越用越准”的算法它最怕没有行为。新用户刚注册时系统对他一无所知这时候协同过滤直接失灵。常用的兜底手段是热门榜、新用户偏好问卷、基于渠道来源的粗粒度推荐本质上是用“群体经验”先给用户一个过渡期等积累到一定行为量再逐步切换成个性化推荐。新物品的冷启动是另一条线。一个物品刚上架没有任何人产生过行为它就像一座孤岛无法和任何已有物品计算相似度。工程上的通用做法是先让新物品进入“探索池”用基于内容的特征或人工规则给它分配曝光机会等行为量够了再纳入协同过滤的候选池。切忌一上线就直接参与相似度计算那样它往往会被冷落到底部。核心原则是“给数据留出积累期”。推荐系统不是算一次就结束它需要持续喂数据冷启动阶段的主要矛盾不是算法有多聪明而是如何低成本地把第一批行为数据收集上来。4.2 稀疏性共同评分太少导致相似度虚高相似度计算有个反直觉的现象共同评分越少相似度反而越容易算得很高。两个用户只共同看过一部片评分又恰好一致皮尔逊就算出 1.0仿佛他们灵魂共鸣实际上只是巧合。这是一个高频踩坑点。应对方法有几种。最简单的是在相似度函数里加“共同样本数下限”常见阈值是 5~10不足就直接按 0 处理。更平滑的方式是引入收缩系数把原始相似度乘以min(1, common_count / k)当共同样本数低于 k 时相似度被温和地拉低而不是一刀切。这样做的好处是数据稍微多一些时相似度不会从 0 突然跳到一个极大值。稀疏性的另一个解法是降低评分粒度的要求。没有评分但有点击、有收藏这些隐式行为也应该参与相似度计算。0/1 行为数据虽然信息量小但覆盖面广把隐式信号和显式评分结合起来相似度会稳定很多。4.3 热门偏差为什么推荐总是不够懂你ItemCF 有一个天然缺陷热门物品会“赢者通吃”。热门物品有海量用户行为它在物品相似度表里和大量其他物品都有连接因此在推荐结果里会高频出现。最后用户的推荐列表越来越趋同个性化大打折扣。这个问题不是算法本身错了而是相似度计算没有对“物品热度”做惩罚。一个常用的修正方式是在计算物品相似度时对共同用户数量做对数惩罚def item_cos_with_penalty(i, j): common [u for u in all_users if i in ratings[u] and j in ratings[u]] penalty 1.0 / math.log(2 len(common)) return item_cos(i, j) * penalty这个惩罚项的意思是两个物品同时被很多人看过它们之间的相似关系可能是“大家都认识”带来的常见性而不是真正的强相关只有被一小撮目标人群共同喜欢才是更个性化的信号。加了惩罚之后相似度的区分度会显著提升。线上输出阶段还可以做多样性重排。先用协同过滤将候选池排序再用规则确保同一类型的物品不连续占位。纯按分数排的结果往往单调用户点几次之后就觉得“推荐来推荐去都是一样的”。4.4 离线指标好线上效果差不少团队在离线评测上忙活很久评测分数往上走线上点击率纹丝不动甚至往下掉。原因通常是三个。第一离线评估指标和线上目标不一致。离线算的是“预测评分准不准”、“历史行为里命中多少”线上看的是点击率、收藏率、转化时长这是完全不同的东西。第二评测数据切分方式出了问题。如果用随机切分历史行为等于让模型“见过未来”评测失真。正确的做法是按时间切分比如让模型只用前 90 天的数据预测后 7 天。第三新物品没有被充分纳入评测集。推荐系统为了追求准确率倾向于推荐老物品这会让系统越来越保守用户逐渐失去新鲜感。排查这类问题最好的办法是搭一套线上小流量对比新算法只影响 10% 的用户和线上旧策略赛马。离线指标负责排查逻辑错误线上小流量负责验证真实效果两者结合起来判断比任何一种单一方式都可靠。5. 落地经验评估指标与推荐系统的整体架构5.1 评估离线效果不能只看准确率很多人评价推荐系统只看“准不准”但推荐系统不是填空题它更像一个“有限资源下的分配问题”。在离线阶段我一般会同时看四类指标指标含义使用场景PrecisionN推荐 N 个物品真正有反馈的占比衡量短期精准度RecallN用户所有反馈行为里被推荐命中的比例衡量覆盖率避免只推热门Coverage全量物品里有多少出现在推荐列表度量系统对长尾内容的挖掘能力Personalization不同用户推荐列表的差异化程度度量个性化水平如果 Coverage 很低说明系统只在热门头部打转换个用户看结果几乎一样。如果 Personalization 很低说明推荐结果过于“大众化”用户感受不到被理解。准确率只是及格线覆盖率与个性化程度决定推荐系统的上限。5.2 推荐结果的可解释性与多样性平衡可解释性在真实产品里被严重低估。ItemCF 天生适合做解释推荐一部作品时可以理直气壮地告诉用户“因为你喜欢过某部作品所以推荐这部风格相近的”。UserCF 也能解释“兴趣和你相近的用户也对它给出了高分”。矩阵分解相对难解释但可以通过后置匹配规则补救比如给推荐结果拼接一句“因为你看过 A所以推荐 B”。多样性是另一个需要主动干预的维度。纯按分数排序用户看到的前 10 条结果可能来自同一类型、同一作者视觉和信息上都单调。常规做法是调整推荐池里的综合占比对不同类型设置最低配额保证排序靠前的列表里有不同类型的内容。这个操作会牺牲一点短期精准度但在留存和体验上往往是加分项。5.3 协同过滤在整套系统中的位置大型推荐系统通常分三段召回 → 排序 → 重排。协同过滤最擅长的是召回阶段它把百万级、千万级的候选池快速压缩到几百条。原因是协同过滤的实时计算成本低离线算好用户向量、物品相似表或隐向量后线上只需一次矩阵乘法或查表。等候选缩到几百条之后再用更重型的行为序列模型去做精排最后用规则做重排。在这个架构下协同过滤不追求极致的精确率而是追求在一个合理可接受的命中率下尽量覆盖长尾、保持速度。它和深度学习不是对立的而是上下游关系。很多团队上线深度模型时发现效果没有提升回头检查才发现是召回层的 ItemCF 太弱好物品根本没有进入精排视野精排再强也无力回天。我自己折腾下来最大的体会是不要一上来就追求复杂的模型先把协同过滤这层基本盘做扎实。数据稀疏的时候一个参数调合理的 ItemCF 往往比一个花哨的深度模型更实用、更稳定。基于评分和行为的协同过滤永远不是最炫酷的方案但它是最可靠的那块地基。后续想往上升级也别忘了给这套基础方案留好埋点、留好评测口径、留好快速切换的开关。地基牢了上面盖什么都稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑