拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

基于Python协同过滤的淘宝商铺推荐系统实现与调优

基于Python协同过滤的淘宝商铺推荐系统实现与调优

简介:面向电商推荐系统学习者和Web开发者的完整项目源码,基于Python、Django与Vue.js实现淘宝商铺个性化推荐。项目采用Scrapy爬虫采集商品及用户行为数据,通过基于物品的协同过滤算法计算相似度并生成推荐列表,前后端分离架构,覆盖从数据抓取、存储到推荐展示的完整流程。压缩包共122个文件,核心为40个Python源码及36个编译后的pyc文件,含后端逻辑、算法实现与爬虫脚本,另有26个JavaScript文件和7个CSS文件支撑前端界面,附带SQLite数据库、配置文件、说明文档及页面图标等静态资源,整体仅1.77MB,结构紧凑便于下载与部署。目前已有378人学习,适合用于课程设计、毕业设计或电商推荐算法入门实践。通过这套资源可快速掌握Scrapy采集流程、Django接口开发、Vue组件化页面搭建及协同过滤算法落地要点,还可基于现有代码扩展功能或替换数据集,直接复用其目录设计思路。

1. 一个 python 压缩包,装着一套能跑通的淘宝商铺推荐系统

打开这个python基于协同过滤的淘宝商铺推荐系统.zip,本质上是拿到一个完整的教学型推荐系统项目:Python 写的数据处理脚本、协同过滤推荐引擎、以及一份用于演示的淘宝商铺数据样本。它的作用是让你在本地用pandas和numpy就能复现“用户浏览/收藏/购买商铺”到“给用户推荐相似商铺”的全链路,适合正在做毕设、准备转行推荐系统方向、或者想给电商项目加一个“猜你喜欢”模块的开发者。和商品推荐不一样,商铺推荐的粒度更粗、行为更稀疏,这套系统的核心在于用 item-based 协同过滤把“商铺-用户”矩阵的相似度算出来,然后做 Top-N 推荐。它在设计上也相当克制:数据量控制在单机内存能跑动的级别,没有吹分布式,没有硬凑深度学习,老老实实走“相似度计算—评分预测—排序输出”的经典路线。

2. 协同过滤选型:为什么商铺推荐更适合 ItemCF 而不是 UserCF

2.1 先从“人找店”和“店找人”的落地场景说起

淘宝商铺推荐的业务诉求非常直接:用户没有明确的搜索词,系统根据他过往的行为猜他接下来想逛哪家店。这里有两个物理事实:用户数量远大于商铺数量(流量集中在头部店铺),用户行为极其稀疏(多数用户一个月只产生几十条商铺行为)。如果在这条场景上用 UserCF,要实时维护“和你兴趣相似的人”的集合,在线模块改一个用户的画像就要重算邻居关系,行为特征一变就失效。而 ItemCF 先离线把商铺的相似度矩阵算好,在线阶段只需要查表:用户对哪些店有正向行为,直接取这些店的相似商铺作为候选,再按用户自己的偏好强度加权排序。这个流程非常适合“商铺为推荐主体”的场景。

另一个直接原因在于可解释性。UserCF 给出的推荐理由是“和你相似的用户喜欢这家店”,用户想看穿这层关系很费力;而 ItemCF 给的解释是“你收藏过的 X 号店铺,和这家店有很高的相似度”,在电商 App 的推荐卡片上直接就能展示为“看了又看”这一栏。实际部署时,线上推荐流里用户对“理由可感知”的反馈率提升非常明显,这也是业内把 ItemCF 作为召回层标配的原因。最后还需要考虑冷启动恢复能力:一个只有一两次交互的新用户,UserCF 几乎找不准邻居,但 ItemCF 只需要他点过的一家店,就能顺着相似商铺链路给出候选。

2.2 相似度计算的两种底座:余弦相似度与皮尔逊相关系数

商铺相似度是整个系统的心脏。最常用的计算方式是余弦相似度。把每个商铺表示为一个向量,向量的维度是所有用户,向量的每个值是用户对该商铺的行为强度,两个商铺之间的相似度就是两个向量夹角的余弦值。公式是cos(A, B) = (A·B) / (|A| * |B|),在 Python 里用numpy一行可以算完:

import numpy as np def cosine_similarity(vec_a, vec_b): dot = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b)

这段代码里最关键的是分母的模长归一化。淘宝商铺数据里,有些店铺被几万人标记过“收藏”,有些店铺只有几十个人收藏,如果不除以模长,热门商铺和任何商铺的相似度都会虚高,直接导致推荐列表全被头部大店刷屏。除以模长之后,相似度描述的是“方向一致性”而不是“规模大小”,这才能把长尾的小众商铺也拉进候选池。另外注意那个norm_a == 0的判断,数据清洗不彻底时向量可能全为 0,直接除会得到 nan,这个兜底务必保留。

皮尔逊相关系数的差别在于它额外做了均值中心化,公式是sim = Σ((x - x̄)(y - ȳ)) / sqrt(Σ((x - x̄)²) * Σ((y - ȳ)²))。它的好处是能消除用户打分尺度不同带来的偏差,比如 A 用户习惯给小店都打 1 分、给旗舰店打 5 分,B 用户刚好反过来,余弦相似度会认为这两个用户没有共性,皮尔逊认为减去均值之后趋势一致。因此当你的“行为强度”字段不只是 0/1,而是带有明确的浏览、收藏、加购、购买多级权重时,我优先使用皮尔逊。商铺推荐里行为字段天然就是多级的,所以通常的做法是两种都算一遍,召回结果差异大的商铺单独排查。

2.3 用 Python 直接手写相似度矩阵,不调第三方推荐库

很多初学者一上来就pip install surprise或者implicit,但这两个库的 API 是围绕“评分预测”设计的,拿来做商铺 Top-N 推荐需要做不少数据格式适配。更麻烦的是,如果你交上去的代码别人跑不起来,环境问题比算法问题更致命。我一般建议在pandas和numpy环境下把相似度矩阵手写出来,这版代码既能在 Jupyter 里复现,也能直接扔进 Flask 接口做服务。用pandas构造一个“商铺-用户”的评分矩阵:

import pandas as pd # data.csv 字段: user_id, shop_id, behavior, weight df = pd.read_csv('data/shop_behavior.csv') # 行为权重映射:浏览1分、收藏2分、加购3分、下单4分 behavior_weight = {'view': 1, 'collect': 2, 'cart': 3, 'buy': 4} df['score'] = df['behavior'].map(behavior_weight) # 构造商铺-用户矩阵,行是商铺,列是用户,空位补0 shop_user_matrix = df.pivot_table( index='shop_id', columns='user_id', values='score', fill_value=0 ) print(shop_user_matrix.shape)

pivot_table会把缺失的交互填成 0,这在数学上等价于“该用户对该商铺没有行为”,符合矩阵补零的直觉。但要留意一个细节:如果样本中某个用户只有一条“浏览”记录,他的向量就会极度稀疏,和任何商铺算出来的余弦相似度都会被这一个非零值主导。后续在避坑章节会展开讲这类稀疏噪声怎么处理。至于为什么不用 Spark、不用分布式,原因很简单:这套代码在 10 万级用户、1 万级商铺的数据规模下,内存占用不超过 2GB,单机跑完整条链路只需要几分钟。只有当商铺数量到百万级、交互记录到亿级,才需要考虑用 Spark 重写相似度计算。热词里提到的“基于 Spark 的电商系统推荐”是另一条路线,本文不展开。

3. 从原始行为日志到商铺评分矩阵:数据清洗的三道关卡

3.1 行为数据的去重策略:保留最强的那个行为,而不是全部保留

淘宝商铺推荐的数据入口通常是埋点日志,同一对(用户,商铺)可能同时存在浏览、收藏、加购、支付四种行为。直接把这四行都塞进 pivot_table,同一个用户在矩阵的同一点上会被后来者覆盖,结果随行顺序漂移,推荐结果变得不可复现。常规做法是聚合成“用户对商铺的最强行为”,即如果既有浏览又有购买,只保留购买这一条。这样既避免了重复,也让评分矩阵的物理含义变得清晰:

# 按 user_id, shop_id 分组,取权重最大的行为作为该对的最终得分 df['score'] = df['behavior'].map(behavior_weight) df_dedup = ( df.sort_values('score', ascending=False) .drop_duplicates(subset=['user_id', 'shop_id'], keep='first') ) # 过滤掉完全没有区分度的账号:行为数少于5的用户保留意义不大 user_active_count = df_dedup.groupby('user_id').size() active_users = user_active_count[user_active_count >= 5].index df_filtered = df_dedup[df_dedup['user_id'].isin(active_users)]

drop_duplicates先按分数升序排列,再按(用户,商铺)去重,keep='first'留下的就是分数最高的那条。这里有第二个关键过滤:行为数少于 5 的用户直接剔除。他们可能是刚注册的测试号,也可能是爬虫扫店留下的垃圾行为,这些用户会让相似度矩阵出现大量只有一个非零值的行,拉低所有相关计算的稳定性。行为数阈值的设置需要根据数据规模调整,1 万条以下建议阈值设为 3,10 万条以上可以提到 10。

3.2 商铺维度的频次裁剪:头腰部店铺建索引,尾部店铺不参与相似度计算

日志里大量商铺只有个位数的交互记录,参与矩阵运算后不仅拖慢速度,还会在相似度计算时产生“共现次数过低”的伪相似。两个只被同一个用户点过的店铺,余弦相似度可能高达 1.0,但这完全是偶然共现,不具备推荐价值。所以要做商铺侧裁剪:只有交互次数超过阈值(通常取 20)的店铺才进入相似度矩阵,其他商铺在推荐阶段用“热门兜底”策略覆盖。

shop_count = df_filtered.groupby('shop_id').size() keep_shops = shop_count[shop_count >= 20].index df_use = df_filtered[df_filtered['shop_id'].isin(keep_shops)] shop_user_matrix = df_use.pivot_table( index='shop_id', columns='user_id', values='score', fill_value=0 ) print(f'有效商铺数: {shop_user_matrix.shape[0]}, 有效用户数: {shop_user_matrix.shape[1]}')

这个裁剪是刚性的:尾部商铺虽然数量多,但对推荐系统整体质量的贡献趋近于零,而且会引入大量噪声相似对。保持候选池在 2000 到 5000 个商铺是参与交互运算的合理区间,计算相似度矩阵的复杂度是 O(n²·m),n 是商铺数,m 是用户数,把 n 从 5 万砍到 3000,运算量直接降两个数量级。

3.3 训练集和测试集的切分:按时间切,而不是按行随机切

推荐系统评估最忌讳随机切分,因为推荐天然依赖时间顺序:用 3 月份的数据训练,去预测 5 月份的行为,才贴近线上真实。如果随机切分,测试集里会混进训练集同期的行为,评估指标虚高。常见的做法是时间戳字段存在的话,按 80/20 的时间比例把交互记录一份为二:前 80% 时间段内的行为作为训练矩阵,后 20% 时间段内的行为作为验证。

df_use['ts'] = pd.to_datetime(df_use['ts']) split_ts = df_use['ts'].quantile(0.8) train_df = df_use[df_use['ts'] < split_ts] test_df = df_use[df_use['ts'] >= split_ts] # 测试集里只保留训练矩阵中存在过的用户和商铺,保证可计算性 train_users = set(train_df['user_id']) train_shops = set(train_df['shop_id']) test_df = test_df[ test_df['user_id'].isin(train_users) & test_df['shop_id'].isin(train_shops) ]

用quantile(0.8)切分的好处是不用写死具体日期,数据更新后阈值自动跟着分布走。最后一步过滤测试集里的新用户和新商铺,是因为协同过滤没有对完全陌生的实体做过学习,把它们留在测试集里只会让指标难看,不等于算法变差。真正的冷启动评估要单独做,不混在这条链路里。

4. ItemCF 推荐引擎的实现:从相似商铺库到个性化 Top-N 列表

4.1 构建商铺相似度矩阵:用字典存储稀疏相似对更高效

用pandas.DataFrame存商铺相似度矩阵会浪费大量内存,因为大部分商铺对的相似度是 0 或者低到无意义。实际工程常用字典套字典的结构:外层键是商铺 id,内层是“邻居商铺 id → 相似度值”。计算流程是遍历每个商铺,取用户向量,再和其余商铺做余弦计算。直接双重 for 循环在 3000 个商铺规模下是九百万次向量点积,性能压力不小。常见做法是先转成numpy数组,用矩阵乘法一次性求所有对的点积。

import numpy as np matrix = shop_user_matrix.values shop_ids = shop_user_matrix.index.tolist() # 归一化后做矩阵乘法:点积结果就是余弦相似度 norm_matrix = matrix / np.linalg.norm(matrix, axis=1, keepdims=True) sim_matrix = np.dot(norm_matrix, norm_matrix.T) # 只保留相似度大于 0.1 的邻居,稀疏化存储 sim_dict = {} n = len(shop_ids) for i in range(n): neighbors = {} for j in range(n): if i == j: continue score = sim_matrix[i, j] if score > 0.1: neighbors[shop_ids[j]] = round(float(score), 4) sim_dict[shop_ids[i]] = neighbors

np.linalg.norm(axis=1, keepdims=True)先做行归一化,然后一次矩阵乘法就把所有商铺对的余弦相似度算完,比 for 循环快一到两个数量级。相似度阈值 0.1 是一个经验值:调大了召回变少,调小了噪声对会进入候选。数据清洗质量高的情况下可以放到 0.15,但稀疏数据下 0.1 更稳。注意round(float(score), 4)是为了压缩存储,也给后续排序减少无意义的浮点尾数干扰。

4.2 召回与打分:为什么只用“强行为商铺”做种子,而不是全量历史

在线推荐的时候,如果把用户所有交互过的商铺都作为种子去召回,计算量会随用户历史长度线性膨胀,而且浏览过但没产生兴趣的弱信号商铺会把候选列表带偏。常规策略是只取分数不低于 2(即收藏及以上)的商铺作为种子,浏览行为只作为冷启动的备用信号。这样既保证召回基于真实兴趣,又控制了计算规模:

def recommend_for_user(user_id, top_n=10): # 取该用户在矩阵中的评分向量 if user_id not in shop_user_matrix.columns: return [] user_vector = shop_user_matrix[user_id] # 种子商铺:行为分数 >= 2 且相似度字典中存在邻居 seed_shops = user_vector[user_vector >= 2].index.tolist() if not seed_shops: # 无强行为时降级:取最近浏览过的3个商铺做种子 seed_shops = user_vector[user_vector > 0].index.tolist()[:3] if not seed_shops: return [] score_bucket = {} for shop in seed_shops: for neighbor, sim in sim_dict.get(shop, {}).items(): if neighbor in seed_shops: continue # 已交互过的商铺不再推荐 # 权重 = 用户对种子的偏好分 * 商铺相似度 score_bucket[neighbor] = score_bucket.get(neighbor, 0) + user_vector[shop] * sim # 按累计得分排序,取前 top_n ranked = sorted(score_bucket.items(), key=lambda x: x[1], reverse=True) return [shop_id for shop_id, _ in ranked[:top_n]]

打分逻辑只看种子商铺对候选商铺的“加权贡献”,用户对种子店铺的偏好分数越高、种子与候选的相似度越高,候选得分越大。这里有一个很关键的工程决定:不做归一化。如果不除以种子数量,历史行为多的用户分数天然偏高,排序本身不受影响。但如果你想把分数对外展示为百分比或星级,就需要按种子数量和最大可能得分做缩放。代码里user_vector[shop] * sim的乘积在数值上常小于 1,多次累加结果稳定,阈值不是必需品。

4.3 三大必调参数:相似度阈值、种子行为阈值与邻居候选数量

参数调优是推荐系统里玄学最多的环节,但商铺推荐这个场景里有效的参数其实只有三个。第一个是相似度阈值,上文代码里的0.1,决定候选池大小。调到 0.06 时召回量几乎翻倍但准确率下降,调到 0.2 时推荐结果趋于保守,全是和种子店铺几乎雷同的店。第二个是种子行为阈值,即user_vector >= 2这个过滤条件。收藏起步是合理的,如果把浏览也算进来(阈值降到 1),推荐列表会偏向热门流量店铺,因为浏览行为的信号噪声比太低。第三个是 top_n 裁剪,不要直接对所有相似邻居累加分数,先对每个种子的邻居按相似度取前 30 个再进入累计,能显著降低长尾商品的偶然共现干扰:

NEIGHBOR_LIMIT = 30 for shop in seed_shops: # 先给当前种子的邻居按相似度排序,截断到前30 sorted_neighbors = sorted( sim_dict.get(shop, {}).items(), key=lambda x: x[1], reverse=True )[:NEIGHBOR_LIMIT] for neighbor, sim in sorted_neighbors: if neighbor in seed_shops: continue score_bucket[neighbor] = score_bucket.get(neighbor, 0) + user_vector[shop] * sim

NEIGHBOR_LIMIT不要设置太大,30 到 50 之间是实测比较舒服的区间。选太大则长尾噪声进入打分,太小则热门大店垄断相似邻居。实际调试时可以分别把 20、30、50 跑一遍推荐结果,看推荐列表的多样性变化。判断多样性的粗略方式是看推荐结果中店铺的类目分布:如果 10 条推荐里 8 条都是同一类目,说明邻居截断过大,相似度矩阵已经被头部类目主导。

5. 避坑:zip 伪加密、稀疏矩阵与冷启动,三类翻车现场

5.1 zip 伪加密:解压提示需要密码,但项目原本没有密码

拿到python基于协同过滤的淘宝商铺推荐系统.zip后,很多人在解压这一关就卡住了。WinRAR 提示输入密码,但 README 里没说密码的事。很大概率不是真的加密,而是 zip 伪加密。伪加密是文件头里的“加密标志位”被改成了加密状态,但数据本身没有加密,用常规工具解压会误报。解决方法是修改文件头对应字节,或者直接换用 7-Zip 打开,它在遇到伪加密时会尝试忽略标志位强制解压。如果你用 Python 读取,zipfile模块对这个场景也会抛RuntimeError: File is encrypted,这时可以检查ZipInfo.flag_bits的 bit 0:

import zipfile with zipfile.ZipFile('shop_recommend_system.zip') as zf: for info in zf.infolist(): # flag_bits 第0位为1表示加密,伪加密则数据区未加密 is_encrypted = (info.flag_bits & 0x1) == 1 print(info.filename, 'encrypted flag:', is_encrypted)

如果是伪加密,直接用zf.extractall()通常能正常解出原始文件,因为 Python 的zipfile只检查 flag 位不校验真正是否加密。如果真的加密了,且你确定这个包来自公开渠道,优先检查下载页面附件说明或 README 首行注释,教学项目普遍不会真的加密码。为文件强制做暴力破解没有必要,花的时间远超重新下载的代价。

5.2 整个推荐列表全是热门店铺,调低相似度阈值却没有任何改善

现象:给用户推荐的 10 家店里至少有 7 家是平台头部大店,完全看不到个性化味道。原因有两个:一是数据裁剪阶段没有过滤“公共行为用户”,二是归一化前没有做 IDF 降权。大店拥有大量用户交互,和任何其他商铺计算相似度时,公共用户多导致得分天然偏高。解决:在计算余弦相似度时引入“商铺流行度惩罚”,方式是对交互数取对数进行降权:

# 商铺流行度惩罚因子,交互越多的店权重越低 shop_popularity = df_use.groupby('shop_id').size() popularity_penalty = 1 / np.log(shop_popularity + 1) # 对矩阵的每一行乘以惩罚因子再做归一化 matrix_penalized = shop_user_matrix.values * popularity_penalty.values.reshape(-1, 1) matrix_penalized = matrix_penalized / np.linalg.norm(matrix_penalized, axis=1, keepdims=True) sim_matrix = np.dot(matrix_penalized, matrix_penalized.T)

这个惩罚因子的直观理解是:热门店的相似度被压缩到原来的三分之一,小众店铺的相似度相对抬升。副作用是热门店之间的强相似关系被削弱,偶尔会出现“推荐结果和用户收藏毫无关系”的错觉,所以惩罚因子不能过度,1 / log(n+1)这个非线性折中通常够用。

5.3 冷启动用户:一个用户只有一条浏览记录,推荐结果完全随机

协同过滤对这类用户的输出本来就是不可用的,因为没有任何偏好信号可依赖。不要试图在协同过滤层解决冷启动,它做不到。正确的做法是做一个降级策略:对该用户直接返回商铺热度榜 Top-N,并且每次请求随机偏移几位,避免所有冷启动用户看到完全相同的列表。在服务层加一条分支逻辑:

if seed_shops_count == 0: # 冷启动降级:返回热门商铺榜,并加随机偏移 hot_shops = ( df_use.groupby('shop_id') .agg({'user_id': 'nunique'}) .reset_index() .sort_values('user_id', ascending=False) ) offset = random.randint(0, 5) return hot_shops['shop_id'].tolist()[offset:offset + top_n]

这个偏移技巧是线上常用的小把戏,既保证了曝光内容的质量下限,又制造了个性化差异。需要记住边界:冷启动降级逻辑放在用户维度判断,而不是商铺维度判断。

5.4 相似商铺共现次数过低:两个店铺只被同一个用户交互过,相似度却是 1.0

这条是数据稀疏导致的经典翻车。两个店铺因为同一个用户的行为,余弦相似度被算出来是满值 1.0。放到推荐列表里就成了莫名其妙的关联推荐。解决手段是给相似度乘一个“共现惩罚系数”:两个商铺共同交互的用户数越少,相似度越不可信。推荐在相似度计算后加一层过滤:

# co_occurrence: 两个商铺共同被多少个用户交互过 # 在两个商铺的交互用户集合交集不为空时统计 from collections import defaultdict user_to_shops = defaultdict(set) for uid, sid in zip(df_use['user_id'], df_use['shop_id']): user_to_shops[uid].add(sid) co_count = defaultdict(int) for uid, shops in user_to_shops.items(): shop_list = list(shops) for i in range(len(shop_list)): for j in range(i + 1, len(shop_list)): co_count[(shop_list[i], shop_list[j])] += 1 co_count[(shop_list[j], shop_list[i])] += 1

计算完共现数后,把相似度乘以一个缩放系数,共现次数小于 3 的对直接丢弃。这个操作能去掉大量由偶然共现引起的“幽灵相似对”,代价是需要多一层集合运算,数据量大时耗时明显,但对最终推荐质量的提升非常显著。

6. 离线评估:用准确率与召回率判断你的系统值不值得上线

推荐系统的口碑是跑评估跑出来的,不是调参调出来的。对商铺 Top-N 推荐,最直接的评估指标是Precision@N和Recall@N。对测试集中的每个用户,取他的真实行为商铺集合作为 ground truth,看推荐列表里有多少命中。下面的脚本基于已经切好的test_df计算:

hit_count = 0 total_recommend = 0 total_relevant = 0 for uid in test_df['user_id'].unique(): true_shops = set(test_df[test_df['user_id'] == uid]['shop_id'].tolist()) if not true_shops: continue rec_shops = recommend_for_user(uid, top_n=10) if not rec_shops: continue hits = len(set(rec_shops) & true_shops) hit_count += hits total_recommend += len(rec_shops) total_relevant += len(true_shops) precision = hit_count / total_recommend recall = hit_count / total_relevant print(f'Precision@10: {precision:.4f}, Recall@10: {recall:.4f}')

这样算出来的是全局宏平均,如果用户的真实行为数量极不均等,建议再按用户分别算 P@N 后取均值,避免大行为量用户主导结果。对于这套商铺推荐系统,在清洗干净的数据上,P@10 在 0.1 到 0.3 之间算正常,R@10 在 0.05 到 0.2 之间。不要期待更高的数值,因为商铺粒度下用户可选择的店太多了,模糊性本身就是业务的一部分。除了这两个指标,还可以加一个覆盖率:推荐结果里的商铺数除以总商铺数,覆盖率低于 10% 说明推荐结果集中在头部,个性化不足。最后用第一个用户的推荐列表做一次人工抽样,打印商铺名称和来源种子店,确认推荐链路的业务合理性。我自己的习惯是,每个推荐项目先跑通评估脚本,再开始调参数,否则调了三天可能方向都是反的。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表