ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

推荐系统召回阶段UserCF实战指南:原理、Spark实现与踩坑笔记

推荐系统召回阶段UserCF实战指南:原理、Spark实现与踩坑笔记 在推荐系统里“召回”决定了整个推荐效果的天花板。很多人一上来就盯着精排模型、多目标排序却忽略了一个基础又耐打的方法UserCF也就是基于用户的协同过滤。这篇文章不打算讲那些花哨的深度学习框架只围绕一个主题推荐系统召回阶段的UserCF。我会从它到底解决什么问题讲起把用户相似度的几种计算方式、Spark上的落地实现、离线评估指标以及我在实际业务里踩过的大坑都串一遍。无论你是刚入行的推荐算法工程师还是准备面试想把这部分补扎实都可以直接拿这篇当参考。也许你看过各种“一文看懂推荐系统”的文章但这篇会更像一份从项目里长出来的踩坑笔记而不是教科书。1. 召回阶段为什么还要聊UserCF1.1 从推荐系统整体架构说起一个标准的推荐系统通常可以切成召回、粗排、精排、重排这几个环节。召回负责从全量候选池里快速捞出一批“可能有戏”的物料一般几百到几千个精排再对这堆物料做精细打分。可以这么理解召回是红娘先根据你的条件筛掉一批不合适的人精排是相亲现场再认真聊一下。如果红娘筛人的思路不对后面精排再怎么努力都是白搭。所以我一直跟团队里的小伙伴说召回决定了推荐效果的天花板精排决定的是地板能抬多高。现在很多团队都在追向量召回、图召回、序列召回但传统协同过滤并没有过时。UserCF作为召回路径里最经典的基线方法之一很多项目上线第一版推荐系统都是先跑一个UserCF或者ItemCF把链路打通。原因很简单它只需要行为数据不需要物料内容、不需要画像特征实现成本低解释成本也低。尤其当线上数据还没有积累到一定程度时UserCF的结果往往比一个没调好的深度学习模型更靠谱。1.2 UserCF在召回环节的定位与适用场景UserCF的全称是User-based Collaborative Filtering核心逻辑就是“物以类聚、人以群分”。它和ItemCF最大的区别是ItemCF看物品之间的相似关系UserCF看用户之间的相似关系。放在召回阶段UserCF更适合那些用户兴趣变化快、群体行为能快速反映热点的场景比如新闻资讯、短视频、社区动态、论坛帖子。因为在这些场景里一个用户今天关心科技明天可能就被娱乐圈热点刷屏通过找到相似用户能快速把当前群体关注的行为迁移过来。而电商场景里用户的购买行为往往比较稀疏而且物品之间关系更稳定很多团队会优先用ItemCF或者物品向量召回。但这不代表UserCF在电商里完全没用。我在实际项目里见过不少做电商推荐的团队第一版就是先跑UserCF拿一个Baseline观察召回率指标能到多少再决定要不要升级成ItemCF或向量召回。所以我的结论是UserCF不是一个“过时”的方法而是一个“什么项目都能先用一下”的基准方法。这篇文章后面所有内容都是围绕这个基准方法展开的。2. UserCF核心思路与数学表达2.1 核心假设相似用户有相似兴趣你可以想象一个场景你有个老同学你们俩的口味高度一致他最近迷上了一个小众播客你还不知道。系统通过行为数据发现了你们之间的相似性于是把那个播客推荐给你。UserCF做的事情就是把这个过程自动化。它包含两个阶段先算所有用户两两之间的相似度找到每个用户的TopK相似用户再根据这些相似用户对物品的行为生成对候选物品的偏好分。用公式写出来更直观。对目标用户u和候选物品i假设用户u还没有和物品i发生过交互那么召回分数可以写成score(u, i) sum_{v in N(u)} sim(u, v) * r_{v,i}其中N(u)是用户u的相似用户集合sim(u,v)是用户u和用户v的相似度r_{v,i}是用户v对物品i的偏好值。这个公式看着简单但整个UserCF的工程实现和调优基本都是围绕怎么定义sim(u,v)、怎么算r_{v,i}、怎么选TopK这几个问题展开的。在实际落地时r_{v,i}不能简单只取0和1。如果一个用户反复看了某部电影、反复点击某个商品这种行为的置信度应该比一次误点高得多。因此我们通常会把行为次数、行为类型权重、时间衰减都考虑进去合成一个综合偏好分。这也是后面Spark实现里最容易被新手忽略的地方。2.2 用户相似度计算余弦、皮尔逊、Jaccard用户相似度是整个UserCF最核心的部分。不同的相似度公式对最终召回结果的影响非常大。我常用的有三种余弦相似度、皮尔逊相关系数、Jaccard相似度。余弦相似度的公式是cos_sim(u, v) sum_i r_{u,i} * r_{v,i} / (sqrt(sum_i r_{u,i}^2) * sqrt(sum_i r_{v,i}^2))它计算的是两个用户行为向量之间的夹角适合隐式反馈数据比如点击、播放、收藏。因为隐式反馈没有明确的打分尺度用户A点击了50次用户B点击了2次直接比较绝对值没有意义余弦相似度只看方向能更好度量“兴趣结构”是否接近。皮尔逊相关系数则是在余弦基础上做了中心化处理pearson(u, v) sum_i (r_{u,i} - avg_u) * (r_{v,i} - avg_v) / (sqrt(sum_i (r_{u,i} - avg_u)^2) * sqrt(sum_i (r_{v,i} - avg_v)^2))它会先把每个用户的评分减去该用户的平均分这样能消除用户打分的宽松和严格程度差异。如果你面对的是显式评分数据比如电影推荐里的1到5星评分皮尔逊通常比余弦更稳。Jaccard相似度则最简单jaccard(u, v) |N(u) ∩ N(v)| / |N(u) ∪ N(v)|N(u)表示用户u交互过的物品集合。它只关心用户是不是共同看过某些东西不关心行为次数和评分。这在用户行为极度稀疏、只有0/1数据的时候很实用。比如用户A点击过{a,b,c,d}用户B点击过{c,d,e,f}共同点击是{c,d}交集大小2并集大小6Jaccard相似度就是1/3。三种方法各有适用场景我自己通常的做法如表格所示相似度输入数据优点不足典型场景余弦相似度行为次数/权重对尺度不敏感计算稳定未消除用户自身评分偏差点击、播放、收藏等隐式反馈皮尔逊显式评分消除用户打分偏差数据太稀疏时容易不稳定电影评分、商品评分Jaccard0/1行为简单直观适合冷启动忽略行为强度浏览、点击等纯布尔行为2.3 召回分数的生成逻辑理解了相似度之后再看完整召回流程就很清晰了先用行为矩阵算出用户两两相似度对每个用户选出TopK相似用户然后把相似用户有行为、但目标用户没有行为的物品全部捞出来按照刚才的加权求和公式算分最后按分数从高到低截断TopN作为这个用户的召回结果。这里有一个关键问题为什么一定要选TopK而不是用全部用户来计算我在项目里试过直接全量加权结果一是计算量大到不可接受二是相似度很低的用户会给分数注入大量噪声。比如一个和你相似度只有0.01的用户他对某物品打了高分加进来以后可能把一个本来应该排前面的物品挤下去。K一般取50到200具体看用户行为丰富程度。行为密集的场景可以适当取大一点行为稀疏的场景K取小反而更稳。工程上计算用户相似度时不能真的两两用户做笛卡尔积。常用做法是构建倒排索引先建立物品到用户列表的映射然后针对每个物品下的用户两两配对累加共同交互产生的相似度贡献。这样整体复杂度从O(N^2)降到了O(所有用户对在共同物品上的交互次数之和)是UserCF工程落地的基础。3. 基于Spark的UserCF召回实现全过程3.1 数据准备用户行为日志到评分矩阵UserCF的输入归根到底是一张用户物品行为矩阵。但线上日志不会直接给你这个矩阵我们需要自己做清洗、加权和聚合。以电商系统推荐为例一份最基础的日志大概包含user_id、item_id、action、ts、scene等字段。其中action可能是click、collect、cart、order这些不同行为的意图强度完全不一样。我习惯先把行为映射成基础权重点击记1分收藏记3分加购记5分下单记10分。然后叠加时间衰减用exp(-天数/30)对历史行为做降权半衰期约等于20天。为什么要加时间衰减因为用户兴趣是会漂移的半年前买的婴儿纸尿裤不应该和昨天买的手机权重一样。用Spark写这段数据预处理大概是这样的from pyspark.sql import functions as F behavior spark.table(dwd.user_item_behavior) \ .filter(F.col(dt) 2025-01-01) \ .filter(F.col(action).isin(click, collect, cart, order)) \ .withColumn( base_weight, F.when(F.col(action) click, 1.0) .when(F.col(action) collect, 3.0) .when(F.col(action) cart, 5.0) .otherwise(10.0) ) \ .withColumn(day_gap, F.datediff(F.current_date(), F.to_date(F.col(ts)))) \ .withColumn(decay, F.exp(-F.col(day_gap) / 30.0)) \ .withColumn(score, F.col(base_weight) * F.col(decay)) \ .groupBy(user_id, item_id) \ .agg(F.sum(score).alias(rating))最后得到每对用户和物品的加权评分。这一步最容易被忽视的是重复行为处理。同一个用户一天内点了同一个商品20次如果不加限制他会直接把这个商品变成“强偏好”进而污染后面的相似度计算。我通常会在聚合前先做一个行为截断同一个用户对同一个物品一个场景内最多算3次点击。3.2 相似度矩阵计算的关键步骤在Spark上实现UserCF我通常会走“物品倒排 - 用户对 - 相似度聚合”这条路线。先把行为表转成以item_id为Key的RDD每个item下面挂着一组(user_id, rating)。然后对同一个item下的用户两两配对把rating相乘作为共同相似度贡献。核心代码思路如下item_users behavior.select(item_id, user_id, rating) \ .rdd.map(lambda r: (r.item_id, (r.user_id, r.rating))) \ .groupByKey() import itertools def build_pairs(item_users): user_list list(item_users) for i in range(len(user_list)): for j in range(i 1, len(user_list)): uid1, r1 user_list[i] uid2, r2 user_list[j] yield ((uid1, uid2), (r1 * r2, 1)) pairs item_users.flatMap(build_pairs) \ .reduceByKey(lambda a, b: (a[0] b[0], a[1] b[1]))这段代码里的build_pairs就是对每个物品下的用户做两两配对r1*r2是这对用户在同一个物品上的余弦相似度贡献。reduceByKey之后按用户对聚合的就是所有共同交互物品的“分子”累计值。如果还需要做归一化就得再算每个用户的向量长度然后join回去。生产环境不会用这么朴素的实现但理解这个思路比抄一份复杂代码更有价值。有个很大的坑热门物品下面的用户列表可能非常长直接两两配对会让数据量爆炸。我会先做一步限制每个物品最多只取交互次数最高的前300个用户参与配对。这一步在绝大多数场景下不会损失召回率反而能显著降低计算量同时还削弱了热门物品带来的相似度噪声。3.3 生成TopN召回候选相似度矩阵算完之后下一步是为每个用户生成召回候选。先生成每个用户的TopK相似用户再把相似用户的行为传播给目标用户。其中取TopK相似用户可以用groupByKey加排序实现need_normalize True # 这里假定pairs已经归一化得到 ((uid1, uid2), sim) user_sim pairs.flatMap(lambda ((u1, u2), sim): [(u1, (u2, sim)), (u2, (u1, sim))]) \ .groupByKey() \ .flatMapValues(lambda sims: sorted(sims, keylambda x: -x[1])[:100])拿到每个用户的TopK相似用户之后再和用户行为表做join把相似用户喜欢的物品传播出去。推荐候选生成的伪代码是user_sim.join(behavior.rdd.map(lambda r: (r.user_id, (r.item_id, r.rating)))) \ .map(lambda (user_id, ((sim_user, sim), (item_id, rating))): (user_id, item_id, sim * rating)) \ .filter(lambda (user_id, item_id, score): item_id not in user_history[user_id]) \ .map(lambda (user_id, item_id, score): ((user_id, item_id), score)) \ .reduceByKey(lambda a, b: a b) \ .map(lambda ((user_id, item_id), score): (user_id, (item_id, score))) \ .groupByKey() \ .flatMapValues(lambda items: sorted(items, keylambda x: -x[1])[:200])这一步的排序取TopN就得到了一张召回候选表。实际线上不会直接存200个候选而是会根据后续精排的容量设置通常召回几百个足够。还要注意这里必须把用户自己已经交互过的物品过滤掉否则推荐了一个用户刚买过的商品体验会非常奇怪。3.4 工程优化分块计算、过滤热门物品UserCF最怕的事情就是用户规模大到全量相似度矩阵存不下。如果只有几十万用户上面那段RDD代码直接跑没有问题。但用户量到几千万甚至上亿时用户对数量是千亿级别的这时候必须做一些工程取舍。第一个方案是分块计算。先把用户按照活跃度或者随机hash分成若干块在每个块内部单独计算UserCF。这样相似度只会在同一个块内部共享会牺牲一些跨块召回但换来的是计算量大幅下降。还有一个更优雅的路径是用Spark MLlib里的RowMatrix直接对用户向量矩阵做columnSimilarities底层用分布式矩阵乘法实现比手写RDD的pair生成高效很多。第二个必须做的优化是过滤热门物品。像新闻客户端里的头条新闻几乎所有用户都看过如果完全不处理它在相似度计算中的贡献会淹没掉真正有区分度的长尾物品。我没少在项目里用IDF思想给物品做降权物品i的权重记为w_i log((N 1) / (n_i 1)) 1其中N是总用户数n_i是交互过物品i的用户数。在算两两用户共同贡献时把w_i乘进去。这样“万人嫌”的热门物品对相似度的贡献就变小了。4. 评估指标召回率、精确率、准确率到底怎么看4.1 混淆矩阵与三个指标的关系推荐系统里聊评估指标绕不开精确率、召回率和准确率。这三个指标都来自于二分类混淆矩阵TP表示预测为正且实际为正FP表示预测为正但实际为负FN表示预测为负但实际为正TN表示预测为负且实际为负。准确率Accuracy (TP TN) / (TP TN FP FN)精确率Precision TP / (TP FP)召回率Recall TP / (TP FN)。放到召回场景里我们往往只关心用户真实会喜欢多少但真实会喜欢却推荐漏掉多少。“预测为正”在这里就是“被推荐算法召回进TopN”实际为正就是“用户在测试集里真的产生了正向行为”。举个例子测试集里用户有20个真实喜欢的物品召回策略返回了100个物品命中其中10个那么Precision10/10010%Recall10/2050%。如果另一个策略返回了30个物品命中5个Precision≈16.7%Recall25%。这里必须提醒一句在推荐场景里Accuracy其实不是一个好指标因为负样本是无穷无尽的。用户没有点击某个物品不代表他一定不喜欢可能只是没曝光。所以离线评估里大家更关心Precision和Recall的组合而不是单一Accuracy。4.2 推荐系统评估中的离线指标选择真正做召回评估时我们最常用的指标是RecallK。它的含义是在返回K个候选物品里能命中多少个测试集中的正样本。召回阶段宁可Precision低一点也要优先保证Recall高因为召回之后还有精排精排会继续把不靠谱的候选过滤掉。如果召回阶段就把真实喜欢的物品漏掉了后面精排再强也无济于事。我整理了常用指标以及经验参考指标公式注意RecallK命中正样本数 / 测试集正样本总数召回环节最重要PrecisionK命中正样本数 / K通常只做参考F12 * Precision * Recall / (Precision Recall)需要平衡时使用Coverage被召回物品数 / 全库物品数防止长尾被忽略多样性类目或标签分布的差异度辅助判断推荐体验很多新人会问“召回率建议多少值合适”说实话没有一个固定数值。我见过电商场景里用户行为稀疏Recall100做到10%到20%已经是比较正常的水准内容资讯类用户互动频繁Recall100做到20%到40%也不稀奇视频类产品因为用户观看行为密集Recall100到30%到50%都有可能。关键是自己和自己的Baseline比或者和不同召回策略之间做横向对比而不是盯着绝对数值焦虑。4.3 哪些场景下UserCF的召回率容易翻车UserCF在有些场景下表现很好但在另一些场景下召回率会明显拉胯。第一个典型问题是用户行为稀疏。新用户只有一两次点击找不到足够相似的邻居召回结果基本等于瞎猜。第二个是物品更新速度特别快比如新闻资讯里分钟级上新相似用户的行为里大概率没有这些新物品UserCF天然漏新。第三个是长尾物品冷启动UserCF更倾向于把热度高的物品传出去长尾物品很难被挖掘出来导致整体召回覆盖不够。做项目时不要只盯着整体Recall最好拆维度看新老用户、头部物品、长尾物品分别算Recall。如果发现长尾召回率低可以在UserCF之外加一路内容召回或者热门兜底做成多路召回而不是只靠一条路。这也是为什么很多推荐系统线上是多路召回并行最终再做融合的原因。5. UserCF的高频踩坑与排查实录5.1 用户冷启动问题用户冷启动是UserCF最大的痛。新用户没有任何行为相似度矩阵里根本找不到和他相近的邻居召回结果自然为空。我见过不少新人上线UserCF后一看线上召回命中率掉了第一反应是调参数其实问题根源在于新用户占比太高。处理冷启动用户我一般推荐“分人群走不同策略”新用户先走热门榜、规则召回或者基于用户画像的相似用户映射等攒够一定行为量后再切换到UserCF。另一种思路是给冷启动用户找一个默认相似用户集合比如按性别、年龄、地域同时段活跃用户的行为做一次聚合推荐不过这个方案更偏规则不算纯粹的UserCF了。5.2 相似度矩阵稀疏导致的结果退化行为稀疏不光影响用户冷启动还会让相似度计算本身失真。最典型的场景两个用户共同交互过1个热门物品通过Jaccard算出来相似度可能是1.0但这两个人的真实兴趣可能完全不同。如果把这个相似度直接拿去做加权召回结果会被严重带偏。我解决这个问题时会加一个“最小共同交互数”的过滤条件比如至少共同看过3个物品才参与相似度计算。另一个更平滑的方法是引入Shrinking惩罚项把相似度修正为sim_shrink sim * c / (c shrink_factor)c是共同交互数shrink_factor一般取10到50。意思是当共同交互数远小于shrink_factor时相似度会被压得很低共同交互数越充分相似度越可信。这个小技巧在稀疏数据上非常管用。5.3 热门物品干扰热门物品对UserCF的干扰可以说是最隐蔽的坑。一组用户可能因为都看过当天的头条新闻而显得相似但他们的长期兴趣可能风马牛不相及。如果不对热门物品做处理UserCF推荐出来的结果会越来越向头部集中导致“召回率看着还行但用户体感就是一直推一堆大热门”。我在一个电商项目里实测过把交互用户数排名前1%的热门物品从相似度计算中拿掉之后整体召回率并没有下降反而长尾商品的召回率提升了。后来我在计算相似度时给每个共同物品乘上一个IDF权重这样既没有彻底丢掉热门物品的信息又降低了它们的干扰。5.4 性能问题与应对UserCF的性能问题是绕不开的。用户数量一旦过千万全量用户两两相似度会是万亿级别。就算用倒排索引只计算有共同行为的用户对遇到热门物品下的百万级用户列表局部计算量依旧会爆炸。我的应对思路有三个一是限制每个物品参与计算的用户数比如只保留交互次数最高的前300个用户二是只对活跃用户计算相似度矩阵不活跃用户直接用兜底推荐三是在离线阶段就把相似度矩阵算好线上只做查表和加权绝不在线上去动态算相似度。如果你的用户规模真的非常大还可以考虑用LSH先通过哈希减少候选用户对再精细计算相似度。5.5 数据漂移与行为噪声很多时候召回效果变差问题不在算法而在数据。比如用户点击了一个商品但马上又退了这个点击可能只是误触再比如大量刷单行为会让某些物品的得分虚高。还有更隐蔽的训练集里混入了测试流量导致离线指标虚高线上效果却一塌糊涂。我曾经为了排查一个“UserCF召回率突然下降”的问题折腾了整整一天最后发现是数据管道的分区时间字段没对齐两天的数据混在一起训练样本的分布全变了。所以做UserCF我第一个建议就是先把数据血缘理清楚。行为日志的时间戳、实体ID、曝光未点击的标记、刷单过滤逻辑这些不处理好算法再花哨也没用。建议对行为日志做如下清洗过滤无效用户和无效物品过滤异常高频行为对同一个用户在同一个物品上的重复点击做截断以及在训练和评估里严格按时间切分避免未来信息泄漏。6. 经验总结与进阶方向6.1 什么时候该放弃UserCF说句实话UserCF并不适合所有业务。如果用户量已经过亿且每天需要在线实时更新召回结果UserCF的离线相似度矩阵更新频率可能跟不上用户兴趣变化如果用户行为极度稀疏UserCF的召回率大概率打不过规则或内容召回。还有一些强可控性要求的场景运营希望推荐结果能定向控制ItemCF的“看了又看”形态要比UserCF更容易解释。我在选型时通常按这个原则判断用户规模在百万级、行为数据相对密集、业务又希望推荐结果能发散一些优先用UserCF。如果业务明显是电商、视频这类物品关系更稳定的场景我会先跑ItemCF。复杂度再往上走就切向量召回。6.2 从UserCF到ItemCF再到向量召回UserCF和ItemCF不是非此即彼的关系实际线上常常会一起跑然后做多路融合。我简单对比过这三类方法的差异方法核心逻辑优点不足典型场景UserCF用户相似容易发现新兴趣、跟踪热点用户量暴增时复杂度高、冷启动差新闻、社区、短视频ItemCF物品相似可解释性强、结果稳定很难挖掘新兴趣电商、长视频向量召回行为序列/内容Embedding能融合特征、支持超大规模需要样本构造和模型训练大规模通用推荐如果团队刚起步我建议先用UserCF把召回链路和评估体系建起来再逐步引入ItemCF、矩阵分解ALS、双塔模型。这样既不会一上来就被复杂模型拖住也能在每一步都能看到对比提升。6.3 后续扩展建议如果你想动手实践推荐用MovieLens数据集在Spark上用Scala或者Python实现一版完整的UserCF。用Scala写的时候思路和上面PySpark完全一样只是把RDD操作换成Dataset的map、groupBy和join。重点不是语言而是把数据流转理解透行为表 - 物品倒排表 - 用户对 - 相似度 - TopK用户 - 召回候选。最后再写一个评估脚本算一下Recall100你就能直观感受到UserCF在不同参数下的差异。最后再说一个我自己的习惯每次新项目上线召回策略我不会只盯着整体召回率还会拆维度看新老用户、头部物品、长尾物品分别的召回率。UserCF这种老方法用好了在基线阶段能帮你少挨不少骂。真正踩过坑之后你会发现协同过滤的很多思路并没有过时它只是把“人和人之间的关系”这件事用最朴素的方式表达了出来。先把这一步走扎实再去碰向量召回你的体感会完全不一样。
返回列表