ARTICLE DETAIL

资讯详情

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

Python商品推荐系统实战:协同过滤与FastAPI部署

Python商品推荐系统实战:协同过滤与FastAPI部署 简介基于 Python 的商品推荐系统完整实现方案面向具备一定编程基础、希望从数据处理到推荐算法落地实战的学习者与开发者。压缩包内共 7 个文件包含 4 个 Python 脚本分别承担数据召回、图模型构建、最终排序及整体流程串联另有 2 个 bin 文件用于存储数据或模型中间结果1 个 Markdown 文档提供说明指南整体大小约 37.37MB。项目以约 15 万用户、12 万商品的数据集为背景完整覆盖 Pandas 数据加载与清洗、特征选择与编码、基于内容推荐、用户/物品协同过滤、SVD 矩阵分解、模型训练与评估、交叉验证和参数调优等核心环节并提供了模型保存与在线部署的设计提示同时涵盖稀疏数据处理与相似度计算等实用技巧。已有 414 人学习下载特别适合用于课程设计、毕业设计或推荐系统入门实战借助完整代码与说明文档可快速理解召回、排序等环节的实现思路帮助读者建立起工程化实现推荐系统的整体认知。1. 商品推荐系统用 Python 落地先想清楚一个问题再动手如果你所在的团队准备给商品列表加一个“猜你喜欢”第一反应往往是上深度学习模型觉得“协同过滤”是上个时代的产物。我这两年帮几家公司做过推荐模块实际情况是商品量在几十万以内、用户行为数据不算海量时协同过滤的效果并不输给深度模型而且训练时间从几小时缩到几分钟解释起来也容易得多。这篇内容就是围绕“用 Python 实现商品推荐系统”这条主线讲清楚协同过滤到底怎么做、用什么库、参数怎么定以及我在实际项目里踩过的坑。适合刚接触推荐系统的 Python 开发者也适合想快速给业务接入一个可用推荐模块的工程师。先摆结论多数中小规模的商品推荐需求第一版用协同过滤就够了。2. 协同过滤两大流派UserCF 与 ItemCF 的选型逻辑和手写实现2.1 相似度计算与评分矩阵余弦相似度和皮尔逊相关系数的取舍所有协同过滤算法的起点都是同一张表用户对商品的评分或行为矩阵。行是用户列是商品单元格里可以是显式评分比如 1 到 5 星也可以是隐式反馈比如点击次数、购买次数、浏览时长。矩阵往往非常稀疏因为用户实际产生过行为的商品只占商品总量的很小一部分。构建这个矩阵用 pandas 的pivot_table就能搞定不需要引入额外组件import pandas as pd import numpy as np # 模拟一份评分数据user_id, item_id, rating df pd.DataFrame({ user_id: [101, 101, 101, 102, 102, 102, 103, 103], item_id: [2001, 2002, 2003, 2002, 2003, 2004, 2001, 2003], rating: [5, 4, 3, 4, 5, 2, 3, 4] }) # 缺失位置补 0表示该用户没有对这个商品产生行为 matrix df.pivot_table(indexuser_id, columnsitem_id, valuesrating).fillna(0) print(matrix)这段代码输出就是标准的用户-商品评分矩阵。这里有个容易忽略的点pivot_table之后 index 和 columns 会带上原字段的 name后续做矩阵运算如果报轴不一致的错先用reset_index()或直接.values转成 numpy 数组再处理。有了矩阵之后相似度计算是核心。我一般会在余弦相似度和皮尔逊相关系数之间做选择。余弦相似度直接比较两个向量的夹角from numpy.linalg import norm def cosine_sim(a, b): # 两个评分向量都为 0 时无法计算直接返回 0 if norm(a) 0 or norm(b) 0: return 0.0 return np.dot(a, b) / (norm(a) * norm(b))余弦相似度的特点是只看方向不看长度也就是说用户 101 习惯给 4 到 5 分用户 102 习惯给 2 到 3 分只要他们的评分趋势一致相似度仍然会很高。但如果业务场景里用户的评分尺度差异很明显——比如有人只打高分、有人只打低分——皮尔逊相关系数会更合适因为它先减去各自的均值再计算。实际项目中我更常用余弦相似度原因有两个一是商品推荐场景大多是中国用户的 1 到 5 星显式评分评分分布的偏移没有电影评分那么夸张二是皮尔逊相关系数在稀疏矩阵里更容易出现除以零的情况。要是拿到的是点击、购买这类隐式反馈通常直接把数值当作权重用余弦相似度就够了。2.2 UserCF 手写实现找相似用户聚合出 Top-N 推荐UserCF 的核心逻辑一句话说就是找出和你兴趣最像的一批用户把这些用户喜欢过、而你没碰过的商品按相关性加权排序推荐给你。这套思路在用户数量远小于商品数量的场景下效果好比如新闻资讯、社区内容这类用户基数小、物品流转快的业务。第一步先算所有用户之间的相似度矩阵# 基于前面构建的 matrix计算两两用户的余弦相似度 users matrix.index.tolist() sim_matrix pd.DataFrame(0.0, indexusers, columnsusers) for u in users: for v in users: if u v: continue sim_matrix.loc[u, v] cosine_sim( matrix.loc[u].values, matrix.loc[v].values ) # 看用户 101 与其他人最相似的是谁 print(sim_matrix.loc[101].sort_values(ascendingFalse))嵌套循环在用户量超过一万之后会明显变慢这里只是演示思路。工程上一般先把评分矩阵转成 scipy 的稀疏矩阵再调用pairwise_distances一次性算出全部用户相似度速度能快一两个数量级。然后基于相似用户做推荐def recommend_usercf(user_id, topn3): # 该用户已经评分过的商品推荐时要排除 rated matrix.loc[user_id] rated_items rated[rated 0].index.tolist() # 相似用户排序取前 K 个 k 2 neighbors sim_matrix.loc[user_id].drop(indexuser_id).sort_values( ascendingFalse ).head(k) # 聚合相似用户的评分按相似度加权 scores {} for neighbor, sim in neighbors.items(): neighbor_rated matrix.loc[neighbor] for item, rating in neighbor_rated.items(): if item in rated_items or rating 0: continue scores[item] scores.get(item, 0) sim * rating return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:topn] print(recommend_usercf(101))这个函数里有两个参数直接决定了推荐质量。第一个是k相似用户的数量太小容易受单一用户的口味影响太大又会把相关性很弱的用户也卷进来实际项目中k在 20 到 50 之间比较常见。第二个是加权方式我用的是相似度乘以评分简单直观有些实现会先对相似度做归一化处理再乘以评分效果相差不大。2.3 ItemCF 手写实现构建商品相似度表实时给出推荐ItemCF 的逻辑反过来先找出商品之间的相似关系再根据用户历史行为过的商品推荐和它们相似的商品。电商场景里用户数量动辄百万级而商品数量往往只有几万到几十万用户相似矩阵根本存不下来此时 ItemCF 是更务实的选择。计算商品相似度矩阵时只需要把矩阵转置# 转置后行是商品列是用户 item_matrix matrix.T items item_matrix.index.tolist() item_sim pd.DataFrame(0.0, indexitems, columnsitems) for i in items: for j in items: if i j: continue item_sim.loc[i, j] cosine_sim( item_matrix.loc[i].values, item_matrix.loc[j].values )商品相似度矩阵可以直接落盘保存因为商品数量相对稳定这个矩阵不需要频繁重算。推荐时拿用户买过的每个商品去相似度表里找近邻商品按相似度加权求和def recommend_itemcf(user_id, topn3): # 找到该用户评分过的商品 rated matrix.loc[user_id] rated_items rated[rated 0].index.tolist() scores {} for item in rated_items: # 取与当前商品最相似的其他商品排除已买过的 sim_series item_sim[item].drop(indexrated_items) base_rating matrix.loc[user_id, item] for other, sim in sim_series.items(): scores[other] scores.get(other, 0) sim * base_rating return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:topn] print(recommend_itemcf(101))ItemCF 相比 UserCF 最大的优势在于可解释性系统推荐一个商品可以直接告诉用户“因为你喜欢过某商品所以推荐相似的它”这在电商转化上非常有用。另一个好处是实时性好——用户刚看了某个商品系统立刻能基于这个商品去推荐关联商品不需要重新计算用户向量。选 UserCF 还是 ItemCF我一般只问两个问题用户多还是商品多业务更看重解释性还是惊喜度用户多商品少用 UserCF商品多用户多用 ItemCF需要给用户解释推荐理由就选 ItemCF。3. 用 Surprise 库跑通离线推荐数据加载、交叉验证与三个必调参数3.1 数据集格式与 Reader 配置CSV 评分数据如何被模型识别手写实现适合理解原理真要接到业务里我一般直接上手 Surprise 这个第三方库。它是 scikit-learn 风格的推荐系统库封装了 UserCF、ItemCF、SVD 等多种算法还内置了交叉验证和超参搜索。Surprise 要求数据是三列用户 ID、商品 ID、评分值。时间戳列可选有的话按时间切分做离线回测会很方便。加载数据时有一个非常容易翻车的点必须显式指定Reader的评分范围否则加载直接报错。from surprise import Dataset, Reader, SVD from surprise.model_selection import train_test_split # 假设 df 有 user_id, item_id, rating 三列 reader Reader(rating_scale(1, 5)) data Dataset.load_from_df(df[[user_id, item_id, rating]], reader) # 按 8:2 拆分训练集和测试集 train, test train_test_split(data, test_size0.2, random_state42) print(训练集样本数:, train.n_users, train.n_items)Reader(rating_scale(1, 5))的意思是告诉模型评分的最小值和最大值。如果你的数据是隐式反馈比如点击次数 0 到 100就把这个范围改成(0, 100)。不设置rating_scale时 Surprise 会尝试从数据里推断但数据不足或全为同一分数时会失败所以建议永远显式指定。3.2 模型训练与交叉验证SVD 和 KNNBasic 的对比实验Surprise 里有两类算法最常用基于近邻的KNNBasic对应手写的协同过滤和基于矩阵分解的SVD。两者适用场景不同我会在同一个数据集上跑交叉验证做对比而不是拍脑袋决定用哪个。先看 SVD 的表现from surprise.model_selection import cross_validate algo SVD(n_factors50, biasedTrue, lr_all0.005, reg_all0.02) results cross_validate( algo, data, measures[RMSE, MAE], cv5, verboseTrue ) print(SVD RMSE 均值:, results[test_rmse].mean()) print(SVD MAE 均值:, results[test_mae].mean())再看 KNNBasicfrom surprise import KNNBasic algo_knn KNNBasic( k40, min_k1, sim_options{name: cosine, user_based: False} ) results_knn cross_validate( algo_knn, data, measures[RMSE, MAE], cv5, verboseTrue ) print(KNN RMSE 均值:, results_knn[test_rmse].mean()) print(KNN MAE 均值:, results_knn[test_mae].mean())几个参数解释一下。n_factors是 SVD 分解出来的隐因子数量类似 embedding 的维度。biasedTrue表示考虑全局均值和用户/物品偏置显式评分场景下这种做法能明显降低 RMSE。k是近邻数量min_k是计算预测值时最少需要的邻居数量数据太稀疏时建议把min_k设成 1避免预测时找不到足够邻居直接返回空值。拿我经手的一个真实项目举例商品约 8 万个有效评分记录 120 万条SVD 的 RMSE 在 0.83 左右KNNBasic 在 0.91 左右。SVD 在 RMSE 指标上几乎是稳定胜出但不能据此说 SVD 一定更好——KNN 的预测结果更可解释且训练时间短得多如果业务强依赖推荐理由我会保留 KNNBasic 做近邻召回再用 SVD 做精排。3.3 必调的三个参数n_factors、k 与相似度度量Surprise 的模型参数不算多但真正影响效果的其实就三个。第一个是 SVD 的n_factors也就是隐因子数量。设太小模型学不到足够的潜在特征设太大训练时间变长且容易过拟合。经验范围是 20 到 150我一般从 50 起步然后用网格搜索找最佳值。第二个是 KNN 的k邻居数量。这个参数和数据集本身特点强相关行为数据丰富时k可以设大一些50 到 80稀疏数据则建议 10 到 20太大会把大量无关邻居拉进来稀释权重。第三个是sim_options里的name也就是相似度度量。Surprise 支持cosine、pearson、msd均方差三种。我的选择标准是显式评分首选pearson因为它内置了减均值操作能抵消用户评分尺度差异隐式反馈用cosine更稳妥因为点击/购买的数值没有负值余弦相似度天然适合这类非负向量。三个参数可以一起用GridSearchCV搜索from surprise.model_selection import GridSearchCV param_grid { n_factors: [20, 50, 100], lr_all: [0.003, 0.005], reg_all: [0.02, 0.05] } gs GridSearchCV(SVD, param_grid, measures[rmse, mae], cv3) gs.fit(data) print(最优 RMSE:, gs.best_score[rmse]) print(最优参数:, gs.best_params[rmse])网格搜索是典型的以时间换效果的方案。数据量大时一次全量搜索可能要跑几个小时我通常先在小样本上粗搜确定量级再用最终数据集精搜一小圈。注意GridSearchCV的cv参数不要设太大3 折足够否则训练时间会线性增长。4. 协同过滤避坑指南冷启动、稀疏矩阵与评估指标的四个常见问题4.1 冷启动新用户和新商品没有评分推荐逻辑直接失效现象是两个真实业务场景。第一个新注册用户没有产生任何行为recommend_user_cf里rated_items为空推荐函数返回空列表。第二个运营上架了一个新商品没有任何用户评分它在相似度矩阵里一切相关值全是 0永远不会被推荐出去。原因很本质协同过滤完全依赖历史行为数据。没有历史算法就没有输入。我的处理分成两步走。第一步兜底策略新用户直接推荐全局热度最高的商品新商品则进入一个“新品召回池”随机曝光给部分用户用曝光后的点击反馈逐步积累行为数据。代码上就是一个简单的热度排序# 按商品被评分的次数和平均分计算热度 hot_items df.groupby(item_id)[rating].agg([count, mean]) hot_items[score] hot_items[count] * hot_items[mean] hot_items hot_items.sort_values(score, ascendingFalse) # 新用户先推热度 Top-N def recommend_for_new_user(topn10): return hot_items.head(topn).index.tolist()第二步等用户有了一定行为后立刻切换到基于内容的推荐——用商品的类目、品牌、价格带这些属性找相似商品作为协同过滤的补充。这一步在工程上会多一张特征表但能显著缩短冷启动的“黑暗期”。4.2 稀疏矩阵相似度算出来全是 0 的排查过程现象代码没有任何报错但推荐结果要么是空列表要么返回的商品千奇百怪毫无关联。排查后一看相似度矩阵发现大量商品对之间的相似度是 0。原因评分矩阵太稀疏。假设有 8 万商品平均每个用户只和其中 20 个商品有过交互两个商品被同一批用户评过的概率极低余弦相似度在两个非零向量没有重叠维度时天然等于 0。解决思路从两个方向入手。数据层面过滤掉行为量太少的用户和商品。比如只保留至少有过 5 次评分行为的用户以及被至少 10 个用户评过的商品这个过滤阈值在我做过的项目里通常能让有效相似度比例成倍提升。算法层面用矩阵分解代替近邻计算——SVD 会把用户和商品都映射到低维隐因子空间即使原始矩阵里两个商品没有共同用户它们在隐空间里依然可能距离很近从根源上规避了稀疏性导致的零相似度问题。4.3 热门商品偏移推荐结果千篇一律的原因与降权处理现象推荐列表看着是“对”的热门商品全部排在前列但每个用户的推荐结果几乎一样个性化完全失效。某个日化电商项目里我见过推荐 Top 10 里 8 个是同一款洗衣液的情况。原因协同过滤的评分聚合天然带“流行度偏见”——热门商品和大量用户有过交互相似度计算时更容易成为别人的邻居加权求和后分数自然偏高。系统会越来越倾向于推荐热门商品长尾商品永远没有出头机会。解决这类问题常用做法是对相似度或聚合分数做一次流行度惩罚。最直接的方式是参考 TF-IDF 的思路给热门商品一个衰减系数# 计算商品出现频率并做衰减 item_freq df[item_id].value_counts() max_freq item_freq.max() def popularity_penalty(item, p0.6): # p 是惩罚强度0 表示不惩罚1 表示完全按热门程度反向衰减 return (item_freq[item] / max_freq) ** p # 在 ItemCF 的分数聚合时乘上惩罚系数 # scores[other] scores.get(other, 0) sim * rating * popularity_penalty(other)实际调参时p从 0.3 起步往上加观察推荐列表的多样性指标变化。惩罚越大推荐结果越分散长尾商品更容易被挖出来但精度会同步下降这个度需要针对业务目标反复试。4.4 评估指标只看准确率会让上线效果大打折扣现象离线评测 RMSE 很好看上线后点击率却不升反降。这是推荐系统里最常见的落差之一。原因很直接RMSE 衡量的是“评分预测得准不准”但商品推荐本质上是一个排序问题——系统需要判断一个用户更可能对哪些商品有兴趣而且要有足够的多样性让用户持续看到新东西。两个口径不一致离线表现好不能代表线上效果好。我现在的做法是引入三个排序类指标一起评估。precisionk看推荐列表里有多少是用户真的点击或购买过的coverage看推荐系统覆盖了多少商品——如果覆盖率低于 30%说明热门商品偏移已经很严重了personalization看不同用户之间推荐列表的平均差异度。三者合在一起才能反映这个推荐系统在真实业务里值不值得用。5. 把离线模型部署成推荐接口FastAPI 封装、增量更新与回测验证5.1 模型序列化训练一次推荐接口复用离线模型训练完毕只是第一步真正要让它产生业务价值得把它暴露成一个接口给后端调用。Surprise 的模型和 padas 的相似度矩阵直接用joblib存成文件启动服务时加载进内存即可。import joblib # 训练完成后保存三样SVD 模型、ItemCF 商品相似度表、商品热度表 joblib.dump(algo, model/svd_model.pkl) joblib.dump(item_sim, model/item_sim.pkl) joblib.dump(hot_items, model/hot_items.pkl)这里有一个我自己踩过的坑joblib 的版本要和运行时保持一致。用 1.2 版本的 joblib 保存的模型在 0.16 版本下加载会直接报错换环境时先pip install joblib1.2再启动服务。另外商品 ID 在存储的过程中会被 Surprise 内部转换成连续整数所以保存模型时一定要连同 ID 映射字典一起保存否则上线时传进来的商品 ID 根本对不上。5.2 FastAPI 暴露 /recommend 接口请求参数与返回结构FastAPI 是当前 Python 社区最主流的接口框架把推荐逻辑包成一个 POST 接口非常简单from fastapi import FastAPI from pydantic import BaseModel import joblib import pandas as pd app FastAPI() # 启动时加载模型避免每个请求都重新读文件 svd_model joblib.load(model/svd_model.pkl) item_sim joblib.load(model/item_sim.pkl) hot_items joblib.load(model/hot_items.pkl) class RecommendRequest(BaseModel): user_id: int topn: int 10 exclude_seen: bool True class RecommendResponse(BaseModel): user_id: int items: list app.post(/recommend, response_modelRecommendResponse) def recommend(req: RecommendRequest): user_id req.user_id # 用 SVD 模型预测该用户对所有商品的评分 all_items item_sim.index.tolist() # 如果用户已有行为排除已交互商品 # 这里简化处理实际数据中应从行为表查询 predictions [ (item, svd_model.predict(user_id, item).est) for item in all_items ] predictions.sort(keylambda x: x[1], reverseTrue) # 如果用户没有任何行为直接返回热门商品兜底 if not predictions or predictions[0][1] 3: fallback hot_items.head(req.topn).index.tolist() return RecommendResponse(user_iduser_id, itemsfallback) top_items [item for item, score in predictions[:req.topn]] return RecommendResponse(user_iduser_id, itemstop_items)接口设计上有两个点值得注意。一是predict(user_id, item)这个方法对未知用户 ID 会抛出异常真正生产环境里要先判断用户是否在训练集里不在就直接走冷启动分支。二是返回结构里除了商品 ID 列表最好带一个score字段和strategy字段这样前端可以展示“因为你看过 A所以推荐 B”之类的解释也方便后续排查问题——看到 strategy 是 hot 就知道这个用户没走过推荐主链路。5.3 离线回测用时间切分数据验证线上效果接口写完不能直接上线先用历史数据做一次回测确认推荐效果比直推热门商品有提升。回测最关键的一点是不能随机切分数据因为电商场景里用户行为随季节、大促波动用随机切分会让训练集“偷看”到未来的数据分布。正确做法是按时间切分用前 80% 时间窗口的行为训练把后 20% 时间窗口的真实行为当作正确答案。def evaluate_recommendations(topn_pred, true_items, k10): 计算 precisionk 和 recallk recs topn_pred[:k] hits len(set(recs) set(true_items)) return hits / k, hits / max(len(true_items), 1) # 按时间切分假设 df 有 timestamp 列 train_df df[df[timestamp] df[timestamp].quantile(0.8)] test_df df[df[timestamp] df[timestamp].quantile(0.8)] # 对测试集中的每个用户统计他在测试期真实买过/点过的商品 # 用训练集建模对用户生成推荐再与真实行为对比 true_actions test_df.groupby(user_id)[item_id].apply(list) # 真实行为的商品数量平均有多少决定 k 怎么设 action_lengths true_actions.apply(len) print(测试期平均行为数:, action_lengths.mean())回测跑出来的结果需要和基线对比。我的基线通常是“按热度推荐 Top-N”如果模型推荐的表现打不过这个基线说明数据质量或者模型参数有问题这时候复盘价值比强行上线大得多。回测阶段发现的普遍问题是曝光数据偏差——模型基于用户看到过的商品学习没曝光过的商品天然没有行为记录这会让评估结果虚高。彻底解决需要做倾向性加权但第一版系统可以先接受这个偏差在线上用 A/B 实验校正。做好以上这环推荐系统就不再是一个“离线跑分好看”的黑匣子。我个人的习惯是每次改动先跑时间切分回测再切 10% 线上流量试运行一周看点击率、转化率、覆盖率三个指标对比确认正向再全量。这套流程看起来保守但能避免绝大多数“离线高分、线上翻车”的尴尬局面。从最早用手写协同过滤跑 demo到用 Surprise 做交叉验证调参再到用 FastAPI 包装上线每一步都有对应的坑位。希望这篇笔记能帮你少踩几个让推荐系统真正成为业务的加分项。本文还有配套的精品资源点击获取
返回列表