简介:这是一份面向推荐系统学习者的Python与Spark实践源码包,适合具备一定Python基础、希望从数据清洗、特征工程到模型训练与评估完整跑通推荐流程的开发者,解决从理论到落地的衔接问题。压缩包共70个文件,大小约17.64MB,包含21个Python脚本、6个Scala程序、10个Markdown文档、5个CSV数据文件以及parquet格式数据集,并附有ipynb交互式笔记和Spark运行相关文件,完整涵盖Py3.x实现与Spark MLlib协同过滤等关键环节。目前已有301人学习下载。资源内还整理了manual文档、论文阅读分享和推荐系统基础知识讲解,可帮助使用者理解ALS交替最小二乘法、矩阵分解、数据预处理与特征构造思路;通过实际数据集和Spark分布式实践,既能掌握单机Python实现,也能上手并行化训练,提升构建个性化推荐系统的工程能力。
1. 推荐系统源码实战:为什么我不建议你直接调现成库
做了几年推荐系统相关项目,我最大的感受是:网上能搜到的“python推荐系统源码”很多,但大部分不是让你调一个surprise库跑通MovieLens就完事,就是把电商的召回排序代码封装成黑匣子扔给你。真正到生产环境,数据清洗、样本构造、模型调参、服务化封装每一步都能翻车。这份源码和配套笔记的价值在于,它不是单点代码,而是把用户行为日志到最终推荐API的完整链路拆开了,并且基于Spark实现分布式训练,而不是停在单机玩具层面。适合三类人:一是准备用推荐系统做毕设或课程设计的学生,二是刚转行推荐算法、想看真实工程代码的从业者,三是需要在离线环境复现一套可用推荐服务的后端工程师。接下来我用实际跑通的经验,按数据管道、算法选型、Spark实现、服务化封装这个顺序带你过一遍,顺便把踩过的坑和调参血泪都讲清楚。
2. 算法选型:为什么协同过滤还是主流,以及矩阵分解好在哪
推荐算法选型直接决定后续数据管道怎么设计。不要一上来就上深度模型,因为工业界大部分场景冷启动、数据稀疏、实时性要求高,协同过滤和它的矩阵分解变种依然是性价比最高的起点。这份源码里同时实现了基于物品的协同过滤(ItemCF)和基于ALS的矩阵分解,我在复现过程中发现它们的适用边界差异很大,搞混了会直接导致离线指标好看、线上效果稀烂。
2.1 用户-物品矩阵的稀疏性与相似度计算的坑
协同过滤的第一步是构建user_id, item_id, rating三元组。但原始用户行为不是评分,而是点击、收藏、播放、购买等隐式反馈。我习惯的做法是给不同行为赋不同的权重,比如曝光没点记0分,点击记1分,收藏记2分,完整观看记3分。注意不能直接把次数当评分,否则热门物品会因为天然曝光量大而霸榜。
代码示例:行为序列转评分矩阵的预处理逻辑。
import pandas as pd from collections import defaultdict def build_rating_frame(events_df, behavior_weight): # events_df 至少包含 user_id, item_id, event_type, timestamp df = events_df.copy() # 映射行为类型到权重 df['w'] = df['event_type'].map(behavior_weight).fillna(0) # 同一用户对同一物品可能有多条行为,取加权和再做一个时间衰减 df['decay'] = 1.0 / (1 + 0.01 * (df['timestamp'].max() - df['timestamp'])) df['score'] = df['w'] * df['decay'] rating = df.groupby(['user_id', 'item_id'], as_index=False)['score'].sum() # 过滤掉异常活跃用户,例如行为次数超过全量99%分位的用户 user_cnt = rating['user_id'].value_counts() low_freq_users = user_cnt[user_cnt >= user_cnt.quantile(0.99)].index rating = rating[~rating['user_id'].isin(low_freq_users)] return rating逻辑说明:behavior_weight是行为权重映射表,decay按时间做指数式衰减,让近期行为权重更高。过滤异常活跃用户是为了防止一个用户贡献的score主导整个矩阵。参数说明:0.01是衰减系数,如果数据是按天级的活跃度,可以调到0.001;如果session级,可以调大到0.1。这一步产出的是标准用户-物品评分表,后面ALS和ItemCF都直接吃这个表。
选型上,当用户量远大于物品量时,ItemCF的离线相似度矩阵大小是item×item,计算和存储都可控;反之UserCF的user×user矩阵会爆炸。电影、文章这种物品相对固定的场景用ItemCF比较稳,短视频这种用户兴趣漂移快的场景才考虑UserCF。
2.2 ALS矩阵分解:为什么比SVD更适合并行计算
经典的SVD分解要求矩阵稠密且不能有缺失值,而用户-物品矩阵稀疏度往往在99%以上,直接做SVD要先做均值填充,内存和计算量都扛不住。ALS(交替最小二乘)把这个优化目标拆成两个子问题:固定物品矩阵优化用户矩阵,再固定用户矩阵优化物品矩阵,交替进行。每一步都是最小二乘问题,天然适合分布式并行,所以Spark MLlib里只提供了ALS而不是SVD,也就是这个原因。
这里有个容易忽视的参数:隐式反馈和显式反馈的loss计算方式不一样。显式场景下ALS优化的是(真实评分 - 预测评分)^2;隐式场景下要引入置信度权重,用户行为次数越多,置信度越高。Spark的ALS训练方法里通过implicitPrefs开关切换,同时配合alpha参数控制置信度斜率。我在Ubuntu服务器上跑通这套源码时,第一次直接用默认参数,结果auc比随机高不了多少,后来把implicitPrefs设为True、alpha设为40,效果才正常。
2.3 相似度计算的两种策略与冷启动处理
这次源码包里ItemCF和ALS各有一份实现,两者可以互为补充。ItemCF适合做“看了还看”的关联推荐,ALS适合做“猜你喜欢”的个性化排序。我实际的工程做法是:用ALS产出每个用户的Top N候选集,再用ItemCF对候选物品做一次重排,把强关联物品提到前面,兼顾个性化和可解释性。
冷启动是绕不开的问题。新用户没有任何行为,矩阵分解给不出向量,这时候我一般用两类兜底策略:一类是热门榜,按全局热度填充推荐位;另一类是内容属性画像,新用户先通过登录时选择的兴趣标签映射到种子物品,然后基于种子物品做ItemCF扩展。源码里这部分叫cold_start_filler.py,里面用了一个简单的规则:如果用户历史行为为空,直接返回当天热门榜Top 50,再插入两个随机物品探针,用于后续AB测试的探索。这种做法不是最优解,但能解决线上“接口不能返回空列表”这个硬要求。
3. 数据管道:从行为日志到Rating样本的工程细节
很多开源推荐系统代码把数据管道省略了,直接让你加载一个处理好的CSV。但这是最不工程的地方,因为线上日志的脏数据、重复点击、爬虫流量、用户切换账套都会让模型训练数据失真。真正面向生产的推荐系统源码,七成工作量都在数据管道上。这一章我们讲清楚行为数据到训练样本的完整转换链路。
3.1 字段规约与行为权重配置
先看原始日志长什么样。假设上游给你一张事件表,字段包括user_id, item_id, event_type, channel, device, ts。首先要做的是过滤无效流量,比如device为空、item_id不在内容表中的记录直接不要。然后是去重策略,同一个用户对同一个物品在5分钟内的连续点击只保留一次;但如果间隔超过5分钟再点,说明是二次确认行为,权重应当更高。这段清洗逻辑我放在了data_pipeline/clean_events.py里。
再往下是行为权重配置。我给每类行为设定的默认权重是曝光0.01、点击1.0、收藏2.0、加购3.0、购买5.0,具体数值要根据业务写进配置文件。注意这里有个原则:不同行为的权重差距不要超过一个数量级,否则高权重行为会淹没低权重信号。比如购买权重设为50,点击设为1,那模型基本只学购买行为,样本量不够的话很容易过拟合。
负样本构造也要单独说。显式评分场景有用户主动给的差评,但隐式反馈场景只有正样本,没有负样本。常见的做法是全局随机采样,我当时犯过的错误是随机采样了过多冷门物品做负样本,导致模型把冷门物品全部打成负分。正确做法是保证负样本和正样本的热度分布近似,具体实现是取曝光未点击的物品,按曝光次数加权采样。源码里有一段对应的采样函数。
import numpy as np def sample_negatives(user_items, all_items, exposure_cnt, neg_ratio=3): # user_items: 该用户的正样本物品id集合 # exposure_cnt: 物品曝光次数字典,用于控制负采样分布 candidates = [i for i in all_items if i not in user_items] # 按曝光次数加权,让热门物品更容易成为负样本候选 w = np.array([exposure_cnt.get(i, 1) for i in candidates], dtype=float) w /= w.sum() neg_items = np.random.choice(candidates, size=min(len(candidates), neg_ratio * len(user_items)), replace=False, p=w) return neg_items.tolist()逻辑说明:neg_ratio控制负样本数量为正样本的倍数,一般取2到5。如果取的倍数太高,模型会偏向预测负样本;太低则正负样本不均衡。实际跑下来,3倍在大部分数据集上指标最稳。p=w表示按曝光加权采样,目的是让“用户看过但没点”的物品有机会成为负样本,比随机采样更接近真实语义。
3.2 时间窗口切分:为什么训练集要用时间而非随机划分
划分训练集和测试集是另一个大坑。很多入门代码用train_test_split随机切分,对这种序列行为数据是错的。因为推荐系统要预测的是“未来行为”,如果用随机切分,模型会偷看未来的信息,离线指标虚高。正确做法是按时间切分:取前7天行为做训练,第8天做验证,第9天做测试。更严谨一点可以做回溯测试:用第1天到第7天训练,预测第8天;再用第1天到第8天训练,预测第9天,如此滚动。源码里data_pipeline/time_split.py实现了这种滚动窗口切分。
需要特别提醒的是,切分之后要去掉训练集中出现过的物品对测试集造成的泄漏。具体点说,如果测试样本里的物品在训练集里已经出现过,且用户也出现在训练集里,那么这个预测本身就包含了一定记忆性。我的处理方式是:训练集只保留时间戳早于切分点的行为,测试集只保留时间戳晚于切分点的行为,且测试集里的user_id和item_id都必须在训练集里存在,否则做不了推荐预测。这个过滤会减小测试集规模,但能保证评估标准公平。别嫌测试集小,吃了模型泄漏的亏再回去改成本更高。
4. Spark实现:把ALS跑在集群上并接进Web服务
拿到清洗后的训练样本之后,就可以进入模型训练环节。在单机上用surprise库跑1万用户的MovieLens没问题,但真实场景动辄百万用户、千万物品,单机内存根本装不下用户-物品矩阵。Spark的ALS实现是工业界最常用的分布式矩阵分解方案,这份源码也是围绕PySpark写的,源码量不大,但参数和调用方式值得细讲。
4.1 PySpark ALS参数详解:直接可抄的训练脚本
标准的训练脚本不需要写太多代码,但每个参数都值得认真调。我贴一份生产可用的配置,重点看参数的选取理由。
from pyspark.sql import SparkSession from pyspark.ml.recommendation import ALS from pyspark.ml.evaluation import RegressionEvaluator spark = SparkSession.builder \ .appName("recsys_als") \ .config("spark.sql.shuffle.partitions", "200") \ .getOrCreate() train = spark.read.parquet("hdfs:///data/recsys/train.parquet") als = ALS( userCol="user_id", itemCol="item_id", ratingCol="score", rank=50, # 隐向量维度 maxIter=15, # 迭代次数 regParam=0.1, # 正则系数 implicitPrefs=True, # 隐式反馈开关 alpha=40.0, # 置信度斜率 coldStartStrategy="drop" # 冷启动处理策略 ) model = als.fit(train) # 保存模型,方便后续加载做增量预测 model.write().overwrite().save("hdfs:///models/als_model_v1")逻辑说明:rank是隐向量的维度,维度越高拟合能力越强但越容易过拟合,默认10,常用区间10到100;maxIter是ALS迭代轮数,15在中等数据量下已经收敛,再大收益不大;regParam是L2正则系数,防止过拟合,我一般从0.01开始做网格搜索;alpha只在implicitPrefs=True时生效,表示置信度增长速率,默认40,数值越大,行为次数多的样本影响权重越高,但也导致样本分布更偏。coldStartStrategy="drop"表示遇到训练时没见过的用户或物品,直接丢弃预测结果,不会报错,这个很重要,否则服务上线时遇到冷启动用户,模型在预测阶段会抛异常。
我一般还会加一个动作:把训练好的model的userFactors和itemFactors缓存下来,后面做ANN召回、特征工程都要用。model.userFactors返回一个DataFrame,里面每个用户一行,隐向量在features列。
4.2 用户特征与物品特征缓存到Redis的实践
ALS产出的隐向量不只是用来算推荐,很多时候还能当作用户和物品的embedding特征,喂给下游排序模型。工程上的做法是把隐向量存到Redis,Key的格式用embedding:user:{user_id}和embedding:item:{item_id},Value用pickle序列化或者存成numpy数组字节流。这样做的好处是:后续做相似物品检索、用户向量实时计算,都可以直接从Redis取,不用重新加载Spark模型。源码里embedding_export.py实现了批量导出逻辑。
代码示例:从Spark模型导出隐向量到Redis。
import redis import numpy as np import pickle r = redis.Redis(host="localhost", port=6379, db=0) def export_factors(factors_df, prefix): for row in factors_df.collect(): entity_id = row.id vec = np.array(row.features, dtype=np.float32) key = f"{prefix}:{entity_id}" r.set(key, pickle.dumps(vec)) # 设置48小时过期,防止僵尸key堆积 r.expire(key, 48 * 3600) export_factors(model.userFactors, "embedding:user") export_factors(model.itemFactors, "embedding:item")逻辑说明:factors_df.collect()在数据量大时可能OOM,所以生产环境建议用foreachPartition批量写入,这里写collect是为了演示格式。pickle.dumps把numpy数组序列化成字节串,读取时用pickle.loads还原。设置过期时间很关键,否则每次重训模型都会在Redis里留下旧embedding,导致线上读到的是过期向量。我踩过一次坑,模型重训后忘了清Redis,推荐结果两天没变,排查了半天才发现是Redis里的缓存没刷新。
4.3 推荐接口封装:用Flask包一层能直接用的服务
模型训练好、向量缓存好,接下来就是对外提供API。源码里service/recommend_api.py用Flask写了一个轻量服务,核心逻辑是:收到用户ID后,先从用户隐向量查Redis,然后计算该用户向量与候选物品向量的内积,按Top N返回。这里要注意,如果候选物品有几百万,实时算内积性能是不够的,常见做法是用局部敏感哈希或Faiss做ANN召回,先把候选集从百万缩到几百,再做精确排序。源码里的demo是跑在10万物品级别,直接用numpy批量算内积,超过这个量级请自行换成Faiss。
from flask import Flask, request, jsonify import redis import pickle import numpy as np app = Flask(__name__) r = redis.Redis(host="localhost", port=6379, db=0) item_vectors_file = "item_vectors.npy" item_ids_file = "item_ids.pkl" # 服务启动时加载全部物品向量,用于内积近似 item_vecs = np.load(item_vectors_file) item_ids = pickle.load(open(item_ids_file, "rb")) @app.route("/recommend", methods=["GET"]) def recommend(): user_id = request.args.get("user_id") k = int(request.args.get("k", 20)) raw = r.get(f"embedding:user:{user_id}") if raw is None: # 冷启动,返回热门榜占位 return jsonify({"items": hot_items(k), "source": "hot"}) user_vec = pickle.loads(raw) scores = item_vecs @ user_vec top_idx = np.argpartition(-scores, k)[:k] top_idx = top_idx[np.argsort(-scores[top_idx])] items = [item_ids[i] for i in top_idx] return jsonify({"items": items, "source": "als"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, threaded=True)逻辑说明:item_vectors.npy是物品向量矩阵,行号与item_ids列表的下标对应。np.argpartition比全排序更快,因为它只做部分排序,时间复杂度是O(n)而不是O(nlogn)。冷启动用户走热门榜占位,避免接口返回空列表。这里有一个明显的工程简化:所有物品向量常驻内存,当物品规模到达千万级,内存吃不消,就需要换成Faiss或向量数据库。源码里这个接口的单机QPS能到200左右,瓶颈主要在numpy矩阵乘法。
5. 避坑指南:推荐系统实战中的五个典型翻车点
这一章写的都是我在复现和上线这套源码时真实踩过的坑。每一条都是无脑操作换来的教训,写出来省得你再走一遍分会场。
5.1 现象:离线AUC涨了,线上点击率反而跌了
原因:训练集和测试集划分时用了随机切分,模型偷看了未来信息,离线指标天然虚高。解决:强制改成时间窗口切分,并且保证测试集不包含训练集里出现过的“用户-物品”对。从那以后我每次跑实验前都要先检查数据切分代码是不是用了train_test_split,看到这个函数就直接拉黑。
5.2 现象:Spark ALS作业一直OOM或者shuffle异常
原因:spark.sql.shuffle.partitions设置太低,默认200,但数据量到了几十亿条行为记录时,200个shuffle分区每个分区压力过大。另外ALS的rank设置过高也会导致中间矩阵存储膨胀。解决:把spark.sql.shuffle.partitions调到2000左右,rank先从20开始,内存吃紧时把maxIter降到10。注意不是分区越多越好,分区太多会导致大量小任务调度开销,Ubuntu单机测试时我一般调400就够。
5.3 现象:推荐接口偶发返回500,日志显示“prediction on empty dataset”
原因:用户没有隐向量也没走热门榜兜底逻辑,coldStartStrategy没设对。Spark的ALS预测时如果遇到新用户,默认会抛异常,必须在训练时设置coldStartStrategy="drop",并在接口层加热门榜兜底。解决:在API入口判断用户向量是否存在,不存在直接返回推荐位占位结果。
5.4 现象:推荐列表全是热销爆款,个性化几乎为零
原因:ItemCF的相似度矩阵没有做热门物品惩罚。用户都喜欢热门物品,导致热门物品之间相似度全部虚高,最终推荐结果全被热门霸占。解决:在计算相似度时对物品热度做惩罚,比如乘以log(1 + 热门度)的倒数,或者对相似度做归一化。这个在源码里itemcf/similarity.py已经实现,注意别在改造时把这个惩罚因子去掉。
5.5 现象:模型重训后线上结果没变化,Redis缓存还是旧embedding
原因:重训时没有清理Redis旧key,或者新模型生成的物品ID和旧版本不一致。解决:训练前先生成新的模型版本号,比如als_model_v2,然后把Redis里的key统一换前缀,例如embedding:user:v2:{user_id},切换流量时改一个前缀变量即可。如果直接覆盖写同一个key,一定记得先批量删除旧key。
6. 调优技巧:把召回准确率提上去的三个验证习惯
这套源码跑通不意味着能上线,要上线就必须能评估。我最后分享三个自己一直在用、验证过有效的习惯,全部围绕“上线前如何确认模型能打”。
第一个习惯是离线指标至少看P@K、召回率和多样性三项。不要只盯着AUC,AUC高但推荐列表全是相似物品,用户很快就会腻。多样性可以用推荐列表中不同类目的数量来衡量,代码里可以简单实现为推荐结果的Category集合大小。我通常要求P@20不低于0.4,召回率在0.2左右即可,多样性至少覆盖10个二级类目。如果多样性指标掉得厉害,先怀疑是不是ItemCF被热门物品污染。
第二个习惯是线上小流量验证时,在推荐列表中埋两个随机探针物品。探针是随机插入的低热度物品,如果用户点了探针,说明模型推荐已经收敛到只能命中用户熟悉的领域,探索性不足。这个习惯帮我发现过很多次“模型记住用户只看育儿内容,但用户实际最近在找装修攻略”的情况。探针比例我一般控制在2%流量,样本量过小则数据没有置信度。
第三个习惯是留一份“后悔药”版本。每次训练好的模型不急着覆盖旧模型,用日期和时间做模型文件名后缀,als_model_v1_20250312这种格式。同时把训练数据的时间范围也记录下来,保证出问题时可以一键回滚到上一版。有一次我们重训模型后P@K掉了5个点,排查了两天发现是上游行为日志的埋点字段改了,导致行为权重映射失效。如果没有上一版模型做对比,这个问题几乎不可能定位。
从那以后,我每次训练新模型都会强制走一遍这个流程:先备份旧模型、检查时间切分代码、在测试集上跑P@K和多样性、再小流量探针观察。这一套在Ubuntu单机、Spark集群上都适用,希望帮到你。
本文还有配套的精品资源,点击获取