恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用Python构建商品推荐系统:从特征工程到召回排序全流程解析
首页
资讯中心
/
用Python构建商品推荐系统:从特征工程到召回排序全流程解析
用Python构建商品推荐系统:从特征工程到召回排序全流程解析
发布时间:2026/10/12 19:10:11
简介一份面向数据挖掘与机器学习学习者的Python商品推荐系统实战资源基于约15万用户与12万商品的脱敏数据集完整演示推荐系统的构建全流程适合希望掌握Pandas数据处理、特征工程及推荐算法原理与实现的读者。资源共7个文件压缩包大小37.37MB包含4个Python脚本、2个bin数据文件及1个Markdown说明文档。脚本涵盖用户-商品特征加载与编码、基于内容与协同过滤的相似度计算、SVD矩阵分解、模型训练与K折交叉验证以及最终排序方案bin文件为预处理后的数据存储md文档则对整体流程做了说明。已有414人学习下载内容从数据读取、特征处理到模型优化与部署均有涉及可作为课程设计或推荐系统入门的参考资料。通过该资源读者可理解不同推荐算法的差异并快速搭建一个可运行的离线推荐实验环境。1. 用Python实现的商品推荐系统为什么说这份资源不是玩具项目推荐系统是那种“听起来谁都会、做出来却不敢上线”的方向。这份用Python实现的商品推荐系统数据已经做了脱敏包含约15万用户、12万商品以及配套的用户特征和商品特征。项目包里给出的recall_function.py、build_graph_model.py、final_rank.py、final_solution.py四个脚本分别对应召回、建图、排序和最终整合四个环节。对刚开始接触推荐系统的工程师来说最大的痛点不是不会写SVD或FM公式而是手里没有一份能完整跑通的工程脚本对团队来说则缺一个能快速产出离线结果、用于对比后续模型的baseline。这个项目正好补上这一环。我按自己的拆包习惯把数据加载、特征工程、召回排序、评估避坑和落盘自检依次过一遍读完你应该知道这份资源能做什么、改哪些参数能出效果。2. 数据加载与特征理解拿到15万用户数据后先别急着训练解压项目后我习惯先看README和data目录确认文件之间的调用关系再决定先跑哪个脚本。这类脱敏数据集最大的问题是列名被改得看不出真实含义所以第一步不是写模型而是把每个csv的schema摸清楚。2.1 用Pandas把用户表、商品表和行为表加载进来大部分推荐系统数据集的入口都是三个csv或一个压缩包。用pandas.read_csv加载然后用shape、dtypes、head三件套快速摸底比直接跑训练脚本要稳妥得多。import pandas as pd DATA_DIR ./data user_df pd.read_csv(f{DATA_DIR}/user_features.csv) item_df pd.read_csv(f{DATA_DIR}/item_features.csv) behavior_df pd.read_csv(f{DATA_DIR}/user_behavior.csv) print(user shape:, user_df.shape) print(item shape:, item_df.shape) print(behavior shape:, behavior_df.shape) print(user_df.dtypes) print(item_df.head(3))这段代码不复杂但信息密度高。shape告诉你规模是否与项目描述一致15万用户对应15万行左右的user_featuresdtypes直接暴露哪些列是object、哪些是float或intobject列大概率是需要编码的类别特征head则是肉眼检查第一行是否正常。行为表通常是最重的行数可能是千万级别如果memory吃紧可以给read_csv加usecols参数只保留后面要用的列常见做法是先把列名看全再裁剪读取一次。2.2 特征理解与缺失值处理先区分数值、二值和类别三张脸脱敏特征一般分成三类数值型如年龄、价格、评分、二值型如是否购买、是否点击、类别型如性别、商品类目。这种分类决定了你后面怎么编码。我一般不会把user_df和item_df直接喂模型而是先做两个检查一是列内取值个数二是缺失值比例。for col in user_df.columns: nunique user_df[col].nunique(dropnaTrue) miss_rate user_df[col].isna().mean() print(f{col}: nunique{nunique}, miss_rate{miss_rate:.2%})逐列跑完就能看出哪些列是纯类别、哪些列接近连续数值。nunique很小比如小于20的列通常做LabelEncodernunique很大的列要考虑是不是id或者高基数类别不能直接塞进简单模型。缺失值处理要分情况分类特征可以直接填一个字符串unknown数值特征用中位数或均值填数量很多时直接dropna反而更省事。常见做法是先按缺失率排序缺失率超过50%的列优先考虑删除否则填了也是在灌噪音。看完单个表之后要把行为表和各特征表合并成宽表。这一步最容易翻车的地方是id类型不一致。user_id在user表里是int在behavior表里是objectmerge时怎么都匹配不上行数直接缩水。我一般会在加载后立刻统一id类型再进入合并流程。user_df[user_id] user_df[user_id].astype(int) item_df[item_id] item_df[item_id].astype(int) behavior_df[user_id] behavior_df[user_id].astype(int) behavior_df[item_id] behavior_df[item_id].astype(int) merged_df behavior_df.merge(user_df, onuser_id, howleft) merged_df merged_df.merge(item_df, onitem_id, howleft) print(merged_df.shape) print(merged_df.isna().mean().sort_values(ascendingFalse))merge之后的isna统计非常关键它能告诉你哪些用户或商品特征没有覆盖到所有行为记录。如果某列缺失率突然从0变成30%说明user_df或item_df里有重复主键join时发生了放大或丢失。遇到这种情况先检查源表是否有重复的(user_id)或(item_id)用drop_duplicates清理后再merge这是经验之谈。3. 特征工程与训练集构建把用户和商品塞进同一套向量空间推荐系统的质量上限一半由特征工程决定。数据集是脱敏后的横表特征工程的重点不在于挖掘业务字段而在于把类别特征、数值特征和行为特征整合成模型能吃的东西。我建议先确定“一条样本长什么样”再来谈算法。3.1 特征编码与标准化LabelEncoder、OneHot和高基数特征的取舍类别特征处理上最省事的是LabelEncoder但要注意它只负责把字符串变成数字不负责语义二值型特征用OneHot更直观。用户特征里的性别、商品特征里的类目ID都适合先LabelEncoder再决定。数值特征差别很大的比如价格从0到几千、评分从0到5使用基于距离的模型就必须做MinMax归一化。from sklearn.preprocessing import LabelEncoder, MinMaxScaler cat_cols [gender, category_id, channel] num_cols [price, rating] for col in cat_cols: lbe LabelEncoder() # fit_transform前确保NaN先被填充否则会多出一个未知类别 merged_df[col] lbe.fit_transform(merged_df[col].astype(str)) scaler MinMaxScaler() for col in num_cols: merged_df[col] scaler.fit_transform(merged_df[[col]])这里有两个易错点。一是NaN在LabelEncoder里会被当成一个特殊值导致线上推理时用户填了别的缺失值就崩溃所以先fillna(unknown)再astype(str)更稳二是MinMaxScaler要等特征合并完之后统一做不能拆开对每个表单独归一化。如果某个类别列基数特别高比如商品id或类目id上万种OneHot会生成稀疏矩阵常见做法是换成hashing trick或者直接训练embedding。项目里build_graph_model.py产出的商品向量本质上就是在为高基数id做embedding化表示。3.2 训练集划分与负样本采样别忽略曝光未交互这个隐藏集合推荐系统不能用“全部未交互”当负样本因为用户很可能没见过那个商品而不是不喜欢。多数项目用随机负采样把交互过的当正样本随机抽未交互的当负样本比例在1:1到1:5之间。负样本太多模型会偏向预测低分太少又学不到区分能力。另一个关键点是划分数据集随机划分会导致同一用户的行为同时出现在训练集和测试集评估结果虚高更严格的方案是按用户行为时间排序后取前80%做训练、后20%做验证。import random random.seed(42) all_item_set set(item_df[item_id]) def build_samples(behavior_df, user_df, item_df, sample_ratio4): samples [] for user_id, group in behavior_df.groupby(user_id): pos_items set(group[item_id]) neg_items all_item_set - pos_items # 对每个正样本配 sample_ratio 个随机负样本 for item_id in pos_items: samples.append((user_id, item_id, 1)) selected random.sample(list(neg_items), ksample_ratio) for neg_id in selected: samples.append((user_id, neg_id, 0)) return pd.DataFrame(samples, columns[user_id, item_id, label])这段代码注释了两个参数。sample_ratio越大负样本越多模型预估的分数整体越低排序时不影响相对顺序但会影响你最后切阈值的绝对值random.seed(42)必须写在最前面否则结果不可复现。groupby逐用户生成是为了保证正负样本覆盖尽可能多的用户避免某些活跃用户独占样本集。实际数据量大时可以改用并行化采样或者对交互表做merge操作但先跑通小规模版本确认逻辑正确更重要。4. 召回、建图与排序recall_function.py 到 final_rank.py 的完整链路这个项目最有价值的部分是把召回、建图、排序三个环节拆成了独立脚本。新手容易上来就训练排序模型但真实场景中商品量很大排序模型不可能对全量商品打分必须先做候选集生成。recall_function.py 负责召回build_graph_model.py 构建用户和商品的关系图并产出表示向量final_rank.py 做最终排序。4.1 召回层怎么把候选集从12万商品压缩到几百个召回的目标是快速过滤出用户可能感兴趣的候选集合。常见做法有两种一种是基于物品相似度的协同召回另一种是基于图或embedding的向量召回。这个项目里recall_function.py采用的设计思路更贴近向量召回先对用户历史交互过的商品取平均向量作为用户向量再和全量商品向量做内积或余弦相似度取top-k。import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 假设 item_emb 是 build_graph_model.py 产出的商品向量 def user_embedding(user_id, behavior_df, item_emb): item_ids behavior_df.loc[behavior_df[user_id] user_id, item_id] vecs item_emb[item_ids] return vecs.mean(axis0) def recall(user_vec, item_emb, top_k100): sim cosine_similarity([user_vec], item_emb)[0] return np.argsort(-sim)[:top_k]这里有两个参数容易踩坑。item_emb要用二维numpy数组index顺序和item_df的编码必须一一对应否则取出来的向量全是错的我之前就栽在没对齐id映射上。top_k一般取100到500太小会损失召回率太大会让排序层压力变大。向量召回的另一个常见问题是没有对embedding做归一化导致内积结果被向量模长主导计算cosine_similarity前最好先normalize一下。4.2 build_graph_model.py把交互表变成图再变成embeddingbuild_graph_model.py 的核心是构建一个user-item二部图节点是用户和商品边是交互记录。然后用随机游走生成节点序列再套用word2vec训练出节点向量。为什么用图而不是直接用交互矩阵因为二部图的游走能在不增加大量参数的前提下捕获多跳关系例如“用户A买过商品X商品X被用户B买过”于是用户B的向量和用户A的向量在空间里更接近这种多跳关系是直接矩阵分解难以表达的。游走和训练的参数决定了最终效果。walk_length一般取10到40表示每次游走经过多少节点num_walks每个节点游走次数取5到20embedding_size取64到128window_size取5到10。这些参数和数据规模强相关商品量大就选偏大的embedding_size同时调大步数。随机游走本身是个串行逻辑实现时可以用networkx建图然后手动采样路径。import random import networkx as nx random.seed(42) graph nx.Graph() # 添加双边节点和交互边 for _, row in behavior_df.iterrows(): graph.add_edge(fu_{row[user_id]}, fi_{row[item_id]}) def random_walk(start_node, walk_length20): path [start_node] for _ in range(walk_length - 1): neighbors list(graph.neighbors(path[-1])) if not neighbors: break path.append(random.choice(neighbors)) return pathRandom walk的随机性很大如果不对random.seed固定每次训练出来的embedding都不一样召回结果也会抖动。后续的recall和排序都依赖这份embedding所以我的习惯是固定seed并且把训练好的embedding落盘保存。如果项目数据量很大networkx的迭代方式会偏慢可以换成对邻接表做预处理的批量采样但小规模数据集先用这套代码跑通流程完全足够。4.3 final_rank.py排序层为什么不能直接用召回结果召回只保证候选与用户“大概相关”排序负责在几百个候选中挑出用户最可能点击或购买的。排序层通常做的事是把用户特征、商品特征、用户与商品的交叉特征拼到一条样本上训练一个二分类模型。项目里final_rank.py负责这一步常见的实现是逻辑回归或GBDT。逻辑回归简单可控适合做baselineGBDT对特征交叉的拟合更好但需要更多调参。# 把召回候选集和特征表拼接成排序样本 cand_df recall_result.merge(user_df, onuser_id) cand_df cand_df.merge(item_df, onitem_id) # 交叉特征用户历史主类目与当前商品类目是否匹配 cand_df[cat_match] cand_df[user_main_cat] cand_df[item_cat] from sklearn.linear_model import LogisticRegression features [price, rating, user_active_level, cat_match] X cand_df[features] y cand_df[label] lr LogisticRegression(max_iter500) lr.fit(X, y)交叉特征cat_match这类比单特征重要得多它把“用户最近行为”和“当前商品”的关系带进了模型。如果懒得手工构造可以把user_id和item_id的embedding直接拼接成稠密特征喂给模型。排序模型的负样本来自召回层的负样本不需要再做全局负采样因为排序模型只需要区分“召回回来的候选中哪些更好”。这也解释了项目里为什么recall_function.py先跑final_rank.py后跑两者是流水线关系。5. 模型评估、验证与避坑排查前几章把链路跑通了接下来会面对真正头痛的问题模型效果怎么评估、为什么线下指标好看但线上全崩、为什么每次训练结果都不一样。这一章把评估方法和几个高频坑一次性说清楚这几条都是我实际翻车翻出来的血泪经验。5.1 评估指标与验证方法不要只看准确率推荐系统不是单纯分类问题顺序比绝对正确更重要。线下最常看的几个指标AUC关注排序质量准确率和召回率关注分类能力覆盖率关注推荐商品是否过于头部多样性关注结果之间是否雷同。覆盖率计算时要统计被推荐到商品集合占全量商品的比例能直观反映长尾效应。指标看的重点适用场景AUC排序是否正确离线粗筛模型准确率/召回率用户是否接受推荐分类决策类任务覆盖率是否有长尾问题首页推荐多样性结果是否同质化榜单推荐验证方法上K折交叉验证能减少随机划分带来的波动但推荐场景下数据不是独立同分布同一用户可能横跨多个交互时间点更推荐按时间切分前80%行为做训练后20%做验证。评估时不能只看整体AUC还要按用户活跃度分层看。新用户的AUC通常明显低于老用户这是冷启动问题的正常表现强行追求一致会陷入过拟合。5.2 常见问题排查与避坑记录以下这几条是我拆这类项目时实际遇到过的坑每一条都直接导致过结果异常。现象1训练脚本跑一半内存直接打爆。 原因DataFrame从csv读进来时dtype默认是int64或float64行为数据量大之后merge和groupby会不断复制中间结果内存翻倍。 解决读csv时指定dtype把id列设成int32或category只保留要用的列groupby前先按用户排序并建索引减少shuffle开销。现象2线下AUC很高但线上点击率几乎没变化。 原因特征泄漏。常见泄漏是构造样本时把用户对当前商品的历史点击情况也当作特征模型学到了“用户之前点过这个商品”这个强规则但线上根本拿不到这个信号。 解决检查每个特征的时间边界凡是基于“当前行为”生成的特征必须在训练、验证、线上推理三处严格一致地延迟到样本生成之后。现象3负采样比例一调模型预测分整体漂移。 原因负样本比例变化会改变正负样本的绝对分布二分类模型输出的概率值不是真实点击率而是受训练分布影响的分数。 解决把预测分数当成排序分数使用不在离线阶段纠结概率绝对值需要真实点击率时用Isotonic Regression做校准最终阈值按验证集重新切。现象4固定了seed但embedding训练结果依然不稳定。 原因Word2Vec、随机游走这类算法内部有并行逻辑python的multiprocessing或gensim在并行时即使seed固定也可能因为线程调度产生结果差异。 解决把训练轮数设置为1且使用单线程运行或保存训练好的embedding文件并约定版本不要每次临时训练。现象5调用recall_function时发现候选集大量重复或为空。 原因用户行为列表较短时平均向量退化成单向量且商品id映射没有对齐导致cosine_similarity返回的全是NaN。 解决对每个用户检查行为记录数量少于阈值时补一个全局热门商品兜底候选id对齐要在数据加载时只做一次并用字典映射验证一致性。6. 最后一个技巧模型落盘与自检流程训练结束后模型不会永远活在notebook里最终要落盘给后续服务读取。这一步最常见的问题是训练和推理时特征构造不一致。我的习惯是把特征列名和训练顺序一起保存推理时用同一份列名表重新构造而不是靠记忆手写。import joblib # 训练侧保存 joblib.dump(lr, rank_model.joblib) joblib.dump(scaler, scaler.joblib) with open(feature_cols.txt, w) as f: f.write(\n.join(features)) # 推理侧加载 lr joblib.load(rank_model.joblib) scaler joblib.load(scaler.joblib) feature_cols open(feature_cols.txt).read().splitlines() X_infer scaler.transform(new_feature_df[feature_cols]) score lr.predict_proba(X_infer)[:, 1]feature_cols.txt和scaler必须与模型一起保存否则换台机器或者隔一个月回来你会完全想不起来当时训练的特征顺序。推理前再跑三个自检动作一是模拟一个已知用户看输出score是否在一个合理区间二是对比top-n结果里商品类目的分布防止模型推荐全是一个类目三是打印空特征和NaN数量如果线上特征缺失没有兜底模型会在关键特征上填错。从那以后我每次跑推荐系统项目都强制走一遍这套自检再交出去而不是盯着离线指标就当作能上线。这份项目包里的脚本顺序也是同样的设计思路希望帮到你。本文还有配套的精品资源点击获取