ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

协同过滤落地:美食推荐系统的UserCF、ItemCF与工程优化

协同过滤落地:美食推荐系统的UserCF、ItemCF与工程优化 简介这份《基于协同过滤算法的美食推荐系统研究与实现》为原创学士学位毕业论文面向计算机、信息技术等专业学生与研究人员也适合关注推荐系统与协同过滤算法的读者参考。论文围绕个性化美食推荐场景梳理基于用户与基于物品两类协同过滤思路对算法优缺点、冷启动与稀疏数据问题、混合推荐策略及性能评估实验设置均有展开并涵盖引言、算法原理、系统需求与架构设计、数据预处理、算法实现及性能评估等完整章节可帮助读者把握论文写作框架与实验验证路径。资源包共1个docx文件约29KB结构完整、便于查阅。目前已有621人学习适合需要参考选题、搭建推荐系统实验或撰写相关论文的读者使用。1. 从“今天吃什么”到协同过滤美食推荐系统到底在算什么打开外卖 App首页那排“猜你喜欢”不是运营手填的。一个刚注册的新用户、一个只点过三次麻辣烫的老用户、一个连点一周轻食的减脂用户拿到的推荐位完全不同——背后跑的就是协同过滤算法。它的假设很朴素口味相近的人未来也会喜欢相似的东西被同一批人反复点过的菜大概率也适合你。放到美食推荐系统里问题被具体化成两件事把用户对菜品的“喜欢程度”折成一张可计算的评分矩阵再从矩阵里找出“和你像的人”或“和这道菜像的菜”。餐饮场景的特殊性在于高频、低决策成本、对延迟极其敏感用户滑走一次就不会再回来所以召回要快、理由要直白。适合往下读的有三类人做美食推荐系统毕业设计、需要一条能跑通的完整链路写过推荐功能但离线指标漂亮、上线没人点以及纠结 UserCF 和 ItemCF 在餐饮数据上到底该选哪个的后端工程师。2. 用户-菜品评分矩阵与相似度协同过滤算法落地的两块地基2.1 显式评分与隐式行为怎么折成一张矩阵协同过滤的一切都从矩阵 R 开始行是用户、列是菜品、格子是评分。麻烦在于餐饮场景里显式评分极度稀缺——一百个下单的人里可能只有两三个愿意打星。真正撑起矩阵的是隐式行为下单、复购、收藏、加购、浏览。把它们各自映射成一个 1~5 的强度值再按行为加权求和就能把稀疏的显式评分补成一张能算的矩阵。权重的取值没有标准答案我一般按“行为离真实偏好有多近”来排。复购比首次下单更可信收藏的意图比加购更强浏览是最弱的信号而且容易被首页曝光位置污染。行为类型原始信号建议权重说明显式星级1~5 星1.0直接取值信噪比最高但极稀疏复购次数4.0最接近稳定口味偏好首次下单次数3.0强正向含尝鲜噪声收藏0/12.0有意图未必成交加购未下单0/11.0犹豫信号可能只是比价浏览停留停留秒数0.2需按曝光位去偏合成的分数要截断到 [0,5]。不做截断的话一个复购十次的用户会在某道菜上堆出 40 分相似度计算里这一维会压过其他所有维度等于把矩阵变成了“谁点得多谁说话”。注意矩阵里 0 有两个含义——“没交互”和“交互了但不喜欢”。协同过滤默认 0 是缺失值不能当负反馈用。餐饮数据里差评很少这个假设基本成立如果有大量一星就得把一星单独抽出来当负样本。2.2 余弦、皮尔逊、修正余弦三个相似度公式的取舍选哪个公式取决于数据里“打分尺度差异”和“用户偏差”哪个更严重。余弦相似度看的是向量夹角对绝对数值不敏感实现最简单但完全忽略用户偏置——一个逢菜必打五星的人和一个只给自己真正爱的菜打五星的人在余弦眼里可能很像。皮尔逊相关系数先减去各自均值再算相关天然抵消“有人普遍打高分”适合显式评分但要求两人之间有共同评分的物品共同物品少于一两个时结果会剧烈抖动-1 到 1 来回跳餐饮数据里共同菜品往往只有一两道所以皮尔逊在小样本上很不稳。修正余弦Adjusted Cosine是折中方案减的是“物品被评分的均值”而不是用户均值用在 ItemCF 里效果通常最好因为热门菜天然评分高不减掉这个基线所有菜都会和热门菜相似。公式减什么基线适合餐饮数据上的表现余弦不减隐式 0/1 矩阵、数据稠密简单受打分量纲影响大皮尔逊减用户均值显式评分、共同项多共同菜品少时方差大修正余弦减物品均值ItemCF、评分偏置明显最稳计算略贵实战里的做法是UserCF 用“减用户均值 余弦”等价于皮尔逊ItemCF 用修正余弦。两套公式共用同一套稀疏矩阵改动成本很低。2.3 用 numpy 把评分矩阵和相似度矩阵跑起来先用一份手写的小矩阵把流程走通再换真实数据。下面这段代码做三件事构造用户-菜品矩阵、按用户去均值、算相似度并清零对角线。import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 行用户, 列菜品, 0 表示无交互 R np.array([ [5, 4, 0, 1, 0, 0], [4, 0, 0, 1, 2, 0], [0, 0, 4, 0, 5, 3], [0, 3, 4, 0, 0, 5], [1, 0, 0, 4, 0, 4], ], dtypefloat) mask R 0 # 记录哪些格子真的有交互 cnt mask.sum(axis1) user_mean np.divide(R.sum(axis1), cnt, outnp.zeros(len(R)), wherecnt 0) # 只对已交互的位置去均值, 缺失位保持 0, 否则会被当成负反馈 R_centered np.where(mask, R - user_mean[:, None], 0.0) sim cosine_similarity(R_centered) # 余弦相似度, 逐行归一化后点积 np.fill_diagonal(sim, 0.0) # 自己不和自己比 print(np.round(user_mean, 2)) # [2.5 2.33 4. 4. 3. ] print(np.round(sim, 3))mask是整段逻辑的关键它保证去均值和后续相似度只作用在真实交互上。np.divide里那串out和where是为了防止某个用户一条记录都没有时除零实际数据里这种“纯潜水用户”很常见必须显式兜住。fill_diagonal把自相似度置零否则每个用户最相似的邻居永远是自己TopN 推荐会退化。如果换用 pandasdf.corr(methodpearson)一行就能出相似度矩阵但它对 NaN 的处理是按列成对删除和上面“缺失位补 0”的结果不一致。两套实现混用会得到对不上的离线指标团队里统一一种即可。3. 基于用户的协同过滤实现从最近邻到 TopN 推荐3.1 UserCF 的加权预测公式与代码落地UserCF 的预测公式是带偏置的加权平均$$\hat{r}{u,i} \bar{r}u \frac{\sum{v \in N_k(u)} sim(u,v)\cdot(r{v,i}-\bar{r}v)}{\sum{v \in N_k(u)} |sim(u,v)|}$$拆开看分子是邻居对这道菜的“超额偏好”去掉他自己均值后的部分按相似度加权求和分母做归一化最后再加回目标用户自己的均值。N_k(u)是相似度最高的 K 个邻居只保留相似度为正的——负相关邻居意味着口味相反他们的高分对你是减分项直接剔除比反向加权更稳。def user_cf_topn(R, sim, u, k_neighbors3, top_n3): 给用户 u 生成 TopN 菜品推荐。 R: 用户-菜品矩阵, 0 表示未交互 sim: 已去对角线的用户相似度矩阵 k_neighbors: 邻居数 K top_n: 返回条数 rated R[u] 0 mu_u R[u][rated].mean() if rated.any() else 0.0 # 取相似度为正的 K 个邻居 order np.argsort(-sim[u]) neighbors [v for v in order if sim[u, v] 0][:k_neighbors] if not neighbors: return [] # 无邻居, 交给热门兜底 scores {} for i in np.where(~rated)[0]: # 只在未交互的菜里挑 num den 0.0 for v in neighbors: if R[v, i] 0: # 邻居必须评过这道菜 mu_v R[v][R[v] 0].mean() num sim[u, v] * (R[v, i] - mu_v) den abs(sim[u, v]) if den 0: scores[i] mu_u num / den return sorted(scores.items(), keylambda x: -x[1])[:top_n]k_neighbors不是越大越好。K 从 5 加到 50 时Precision10 通常在某个点见顶然后下滑因为远邻带来的噪声开始盖过信号。下面这组是在一份约 2 万条餐饮行为数据上跑出来的典型趋势只作调参方向参考KPrecision10Recall10说明50.0810.052邻居太少覆盖不足200.1120.094常见甜点区400.1090.106召回略升精度见顶800.0940.113噪声上升精度下滑top_n一般给 50~200因为这道输出是召回层后面还有排序和规则过滤给太少会被过滤空。3.2 一份小数据集跑通离线 TopN 推荐接上面的矩阵直接调用target 0 # 给 0 号用户推荐 recs user_cf_topn(R, sim, target, k_neighbors3, top_n3) dish_names [麻辣烫, 黄焖鸡, 轻食沙拉, 日式拉面, 螺蛳粉, 广式煲仔饭] for idx, score in recs: print(f{dish_names[idx]:8} 预测评分 {score:.2f})输出大致是给 0 号用户推了沙拉、螺蛳粉和煲仔饭因为他和 2、3 号用户在拉面上有共同高分而这两位对沙拉和煲仔饭评价也高。跑通之后再换成真实数据常见做法是从数据库把行为日志按(user_id, dish_id)聚合成一张宽表稀疏度0 的占比通常在 99% 以上用scipy.sparse.csr_matrix存别用 numpy 稠密数组——十万用户乘五千道菜就是 40 亿个浮点数内存直接爆。3.3 稀疏度、冷启动与热门偏置三个坑第一个坑是稀疏度。餐饮数据里单个用户平均交互菜品不到 8 个两个用户共同评过的菜经常是 0 或 1。共同项为 0 时相似度算不出来为 1 时算出来的相似度要么是 1 要么是 -1完全没有区分度。应对办法是设一个min_common_items阈值共同菜品少于 2 的邻居直接不要宁可推荐位空着让热门兜底也别用偶然相似度瞎推。第二个坑是冷启动。新用户没有任何行为UserCF 无从下手。工程上的标准做法是按注册时填的口味标签辣度、菜系、忌口先做规则召回同时快速采集前 3 次点击作为初始信号等交互数超过阈值再切回协同过滤。新菜品的冷启动更棘手ItemCF 里它和其他菜的相似度全是 0永远进不了召回池需要按类目、口味标签、价格带做内容相似度补位。第三个坑是热门偏置。销量前十的菜会出现在几乎所有人的邻居列表里最后推荐结果高度同质化用户点两次就腻。解决办法是对相似度或最终得分做热门惩罚例如除以菜品流行度的对数或者在召回层限定单品类目不超过 30% 的坑位。4. 基于物品的协同过滤与美食推荐系统的工程化改造4.1 ItemCF 在餐饮数据上的优势与相似度计算餐饮场景我一般优先上 ItemCF理由有三条。菜品数量远小于用户数量相似度矩阵规模可控菜品属性稳定今天的相似度明天还能用不像用户兴趣漂移得那么快用户量涨到百万级时UserCF 每来一个新用户就要实时算一遍邻居而 ItemCF 只需查一次离线算好的物品相似度表。ItemCF 的预测同样用带偏置的加权平均只是把“相似用户”换成“相似菜品”用户历史高分菜品 i 和候选菜 j 的相似度越高j 的得分越高。from sklearn.metrics.pairwise import cosine_similarity # 转置后行菜品, 列用户 item_sim cosine_similarity(R.T) np.fill_diagonal(item_sim, 0.0) # 按行归一化, 抑制热门菜品对相似度的稀释 max_per_row item_sim.max(axis1, keepdimsTrue) item_sim item_sim / (max_per_row 1e-9) np.round(item_sim, 3)那段按行归一化是 ItemCF 的经典技巧热门菜品的相似度向量整体偏大归一化之后不同菜品处在同一量纲上长尾菜才有机会被推出来。1e-9是防止某道菜从未被任何两个用户共同评价、整行为 0 时除零。4.2 相似度矩阵的离线计算与 Redis 缓存物品相似度矩阵必须离线算因为它的计算量随菜品数平方增长。常见做法是每天凌晨用 Spark 或一段 pandas 脚本跑全量把每道菜的 Top50 相似菜写成 JSON 灌进 Redis线上只做查表和加权求和单次推荐控制在 10 毫秒以内。Redis 键类型内容过期策略item:sim:{dish_id}String(JSON)Top50 相似菜及分数24 小时rec:user:{user_id}Hash该用户的推荐结果及分数5 分钟hot:dish:{cate}ZSet类目热门菜品兜底1 小时user:profile:{user_id}Hash口味标签、忌口7 天# 物品相似度常驻, 由离线任务覆盖写入 redis-cli SET item:sim:2001 {2002:0.71,2003:0.63,2007:0.55} # 用户推荐结果短过期, 避免每次请求都穿透到算法服务 redis-cli HSET rec:user:10086 item:2001 4.82 item:2002 4.31 item:2003 3.97 redis-cli EXPIRE rec:user:10086 300 # 无邻居无历史的新用户, 直接读类目热门兜底 redis-cli ZREVRANGE hot:dish:spicy 0 9推荐结果缓存 5 分钟是个折中太长会让用户刚点完的菜又出现太短则缓存命中率骤降。用户侧还要做一层“已下单菜品”过滤这层过滤放在缓存读取之后而不是写入之前否则每有一条新订单就要让缓存失效。4.3 用 PrecisionK、RecallK、覆盖率做离线评测离线评测按时间切分用前 80% 的行为训练、后 20% 做测试集避免用未来数据预测过去。核心三个指标PrecisionK 看推得准不准RecallK 看找得全不全覆盖率看推荐池够不够宽。def evaluate(rec_dict, test_dict, all_items, k10): rec_dict/test_dict: {user_id: [dish_id, ...]} hit n_rec n_test 0 recommended set() for u, items in rec_dict.items(): rec items[:k] recommended.update(rec) truth set(test_dict.get(u, [])) hit len(set(rec) truth) n_rec len(rec) n_test len(truth) return { precisionk: hit / max(n_rec, 1), recallk: hit / max(n_test, 1), coverage: len(recommended) / len(all_items), }三个指标要一起看。只盯 Precision 会推出一批永远相同的高分菜覆盖率掉到个位数只盯 Coverage 会为了铺开推荐池牺牲准确率。餐饮场景的经验区间是 Precision10 在 0.08~0.15、覆盖率保持在 30% 以上。如果覆盖率和 DAU 一起下滑先查是不是相似度矩阵长时间没更新或者热门惩罚系数被调得过猛。5. 让美食推荐结果说人话可解释性与线上验证的进阶技巧5.1 推荐理由模板把邻居的行为翻译成一句话UserCF 天然自带解释能力——推荐位下方那句“和你口味相近的人也在点”背后其实是一个具体数字。做法是在预测阶段把贡献最大的那个邻居和菜品一起带出来# 在 user_cf_topn 的分支里记录最大贡献项 best_v, best_w None, 0.0 for v in neighbors: if R[v, i] 0: w sim[u, v] * (R[v, i] - R[v][R[v] 0].mean()) if w best_w: best_v, best_w v, w # 输出时携带: (dish_id, score, best_v) REASON { cf: 和你口味相近的邻居最近点了这道菜, same_cate: 根据你常点的{category}为你挑选, hot: 本周{category}类目下最多人点, }理由不用编造直接从贡献来源映射到一句模板即可。带上理由的推荐位点击率通常比裸推荐位高一截因为用户能看懂“为什么给我看这个”信任成本降低了。5.2 冷启动兜底与灰度对比验证任何一条召回链路都要有兜底顺序ItemCF 召回 → UserCF 召回 → 类目热门 → 全站热门逐级降级保证任何用户任何时刻都有内容可展示。新用户第一次打开时走的其实是最后一级所以全站热门的排序质量同样值得认真调它是系统里唯一一个 100% 曝光的坑位。线上验证用分桶 A/B不要用前后对比。同一个用户群按 ID 哈希切成对照组和实验组观察一周内的人均下单数、推荐位点击率、次日留存三个指标。样本量按日活和预期提升幅度倒推日活一万的 App 想验证 5% 的点击率提升至少跑满两周。指标没动先别急着换算法检查三件事推荐结果有没有被去重逻辑过滤空、新用户占比是不是过高导致协同过滤根本没生效、缓存里的相似度矩阵是不是还是上周的版本。最后补一个容易被忽略的细节相似度矩阵的更新频率和菜品生命周期要匹配。餐饮菜单换季上新频繁如果相似度一天只更新一次新菜在当天完全拿不到曝光。可以在离线全量之外对上新菜品单独跑一次类目内的小规模相似度计算把新菜挂到同口味的老菜上等第二天全量任务再把它正规收编进矩阵。本文还有配套的精品资源点击获取
返回列表