简介:Python协同过滤旅游推荐系统文档是一份面向计算机科学与技术专业学生、毕业设计学习者及旅游信息化开发者的完整范文/模板,定位为本科毕业设计说明书。内容以旅游推荐系统为背景,围绕信息过载下的个性化推荐需求展开:采用Python爬虫采集旅游数据,使用Kettle完成数据预处理并存入MySQL,借助协同过滤算法实现景点推荐,同时设计可视化大屏及景点收藏、热门推荐、路线与乘车方式推荐等功能模块。全文包含中文摘要与英文摘要、目录式章节框架及选题思路,可作为毕业设计开题、撰写和答辩的参考模板。资源包共1个文件,为docx格式文档,大小8.64MB,便于直接阅读、修改和复用。已有547人学习,适合需要快速搭建旅游推荐系统论文框架、理解爬虫+Kettle+MySQL+协同过滤技术路线的学习者。
1. 一份Python协同过滤旅游推荐系统文档,到底能帮你省下多少事
做推荐系统的人都知道,算法原理看十遍不如跑通一遍数据流。如果你正卡在"协同过滤"这四个字上——看过不少python教程,理解了用户-物品矩阵,但真要自己把一份景点评分数据变成"猜你喜欢"的推荐列表,还是会对着NaN和稀疏矩阵发懵——那么这份以Python协同过滤旅游推荐系统为线索的文档,就是给你这样的一线从业者准备的。它不负责科普矩阵分解的数学美感,而是把从数据清洗、相似度计算到Top-N推荐、离线评估的完整链路拆开,让你能照着复现,并且知道在旅游这个特定场景下,哪些步骤是必须改的,哪些默认参数会坑你。
我最早接触这个方向,是因为一个景区App要上线"附近的人还去过哪"功能,数据只有用户ID、景点ID和打分,典型的协同过滤输入。但等真动手才发现,旅游数据和电商评分完全是两回事:矩阵稀疏度超过99%,热门景点挤占了大部分交互,还有明显的季节性波动。这篇文章,就是把当年踩过的坑和最终沉淀下来的方案,按一个可交付的文档结构讲给你听。适合谁?适合会用Python基础语法、想通过一个完整项目掌握协同过滤落地路径的开发者,也适合刚接手推荐系统、需要一份能直接改的业务代码模板的初级工程师。下面从选型开始,一步步往下推。
2. 协同过滤选型:用户-物品矩阵和两种相似度计算,为什么旅游场景默认选ItemCF
2.1 用户行为数据怎么变成矩阵:评分、点击、收藏的归一化
旅游推荐系统的原始数据很少直接是评分。常见的采集渠道是App内的浏览时长、收藏动作、下单购买,以及问卷式的星级打分。文档里最常犯的错误,是把这些不同量纲的信号直接塞进同一个矩阵。比如收藏是0/1,评分是1-5,浏览时长是秒数,三者取值范围差一个数量级,直接拼接会让相似度计算被浏览时长主导。
我一般的做法是先分别处理,再合成一个统一的"偏好强度"列。合成公式可以写成这样:
import pandas as pd import numpy asnp # 原始行为日志 raw = pd.read_csv("behavior_log.csv") # 字段:user_id, poi_id, action, value, ts # action: click / favor / score / order # value: 点击时为停留秒数,收藏为1,评分为1-5,订单为金额 def normalize_action(group): # 每种action独立做min-max归一化 v = group["value"].astype(float) v_min, v_max = v.min(), v.max() if v_max == v_min: group["norm_value"] = 1.0 # 所有值相同则记为1 else: group["norm_value"] = (v - v_min) / (v_max - v_min) return group raw_norm = raw.groupby("action", group_keys=False).apply(normalize_action) # 按权重合成偏好分 weight_map = {"click": 0.2, "favor": 0.4, "score": 0.3, "order": 0.1} raw_norm["pref_score"] = raw_norm["norm_value"] * raw_norm["action"].map(weight_map)这段代码的逻辑是先把每种行为内部做归一化,再用业务权重合成。权重怎么定?如果历史数据显示下单但退款率高,就降低order的权重;如果收藏后基本都会去,就提高favor的权重。文档里会给一组默认值,但你接手后一定要用自己的业务数据重新标定。有一个快速验证方法:把合成后的pref_score和用户实际二次到访率做相关性分析,相关性至少应超过0.3,否则权重配比有问题。
2.2 UserCF与ItemCF的差异:旅游推荐里物品冷启动更致命
协同过滤分两大类:基于用户的UserCF和基于物品的ItemCF。UserCF找的是"和我口味相似的用户",把相似用户喜欢过的景点推荐给我;ItemCF找的是"和我看过的景点相似的其它景点",比如去过故宫的人还去了天坛。文档里如果两个都讲,你要能做出选择。
旅游场景的典型特点是用户数量远大于景点数量,而且单个用户的交互记录非常稀疏。假设一个景区平台有20万活跃用户、3000个POI(兴趣点),用户平均只访问过5个景点,那么用户-物品矩阵的密度约为5/3000=0.17%。UserCF要计算20万用户两两之间的相似度,得到一个20万×20万的矩阵,且每个用户只有5个非零项,相似度计算极不稳定——两个用户恰好都去过同一个热门景点就会被判为高度相似,这显然是噪声。ItemCF只需要计算3000个景点两两之间的相似度,矩阵小一个数量级,而且景点属性相对稳定,相似关系更可信。
所以在旅游推荐系统文档中,默认方案通常是ItemCF。选型的另一个依据是实时性:ItemCF的相似度矩阵可以离线算好,线上只需根据用户当前点击的景点做Top-N召回,响应时间可控;UserCF则需要在用户行为变化时重新找相似用户,在线计算开销大。如果你的系统用户量级在十万以下、景点数更少,UserCF也不是不行,但一旦用户增长,矩阵膨胀带来的内存和计算压力会直接拖垮服务。结论:没有特殊理由,旅游推荐默认ItemCF。
2.3 相似度公式选择:余弦相似度与皮尔逊相关系数的边界
相似度计算是协同过滤的核心。文档里最常出现的两个公式是余弦相似度和皮尔逊相关系数,很多人不知道它们的适用边界。
余弦相似度把每个用户或每个物品看成一个向量,计算夹角余弦。它只关心向量方向,不关心绝对值大小。对评分数据来说,问题在于:一个用户给所有景点都打5分,另一个用户给所有景点都打3分,余弦相似度会认为他们很相似,但实际口味可能完全不同。
皮尔逊相关系数在计算前会先减去各自向量的均值,相当于把打分的尺度偏差去掉。所以它更适合评分数据。但旅游场景的评分矩阵极度稀疏,两个景点的共同评分用户可能只有两三个,此时皮尔逊相关系数的分母(标准差)极不稳定,算出来的相关系数几乎都在±1附近跳来跳去,没有统计意义。
我的经验是分情况处理:如果数据里有明确的1-5分评分且每个用户的评分条数不少于10条,优先用皮尔逊相关系数;如果大多数信号是隐式反馈(点击、浏览、收藏),或者矩阵稀疏到共同评分不足5个,就用余弦相似度并加上一个共同评分数量的惩罚因子。下面给出一个同时实现两种相似度计算的函数:
def compute_similarity(matrix, method="cosine", min_overlap=3): """ matrix: 物品-用户矩阵,行为物品,列为用户(ItemCF视角) method: "cosine" 或 "pearson" min_overlap: 少于该共同评分数量的物品对,相似度直接记为0 """ n_items = matrix.shape[0] sim_matrix = np.zeros((n_items, n_items)) cols = matrix.T # 转为用户-物品视角便于取列 for i in range(n_items): vec_i = matrix[i] for j in range(i, n_items): # 找共同有评分的用户 mask = np.where((vec_i > 0) & (matrix[j] > 0))[0] if len(mask) < min_overlap: continue a = cols[mask, i] # 公共用户对物品i的评分 b = cols[mask, j] # 公共用户对物品j的评分 if method == "cosine": sim = a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8) else: # pearson a_centered = a - a.mean() b_centered = b - b.mean() denom = np.linalg.norm(a_centered) * np.linalg.norm(b_centered) + 1e-8 sim = (a_centered @ b_centered) / denom sim_matrix[i, j] = sim_matrix[j, i] = sim return sim_matrix这个循环是O(N²×M)的,景点数多的时候跑起来很慢。后面第5章会讲怎么优化。参数min_overlap是实际项目里最需要调的:太大会过滤掉大多数有相似性的物品对,导致推荐结果稀疏;太小又会引入噪声。通常设为3到5,如果你的数据极稀疏,可以降到2,但一定要配合后面要讲的去偏逻辑。
3. 用Python实现协同过滤推荐:从数据清洗到Top-N推荐的完整代码
3.1 数据准备:csv读入、用户ID与景点ID编码
文档里给的数据集长什么样?最常见的是一张三列的CSV:user_id, poi_id, rating,也可能是带时间戳的四五列。拿到数据第一步不是训练,是把ID转换成连续的整数索引。因为后续要构建稀疏矩阵,scipy的csr_matrix要求行索引和列索引都是整数,而且最好是从0开始的连续值。直接用字符串类型的Pandas类别编码是最省事的方式:
import pandas as pd from scipy.sparse import csr_matrix df = pd.read_csv("ratings.csv") # 列:user_id, poi_id, rating # 如果rating缺失,用行为发生次数或浏览时长填充 if df["rating"].isnull().any(): # 例如用点击次数替代评分 df["rating"] = df.groupby(["user_id", "poi_id"])["poi_id"].transform("count") # 编码用户与景点ID为连续整数 user_codes, user_unique = pd.factorize(df["user_id"]) poi_codes, poi_unique = pd.factorize(df["poi_id"]) df["u"] = user_codes df["p"] = poi_codes df["r"] = df["rating"].astype(float) # 构建用户-物品矩阵(行=用户,列=物品) user_item_mat = csr_matrix( (df["r"], (df["u"], df["p"])), shape=(len(user_unique), len(poi_unique)) ) print(f"矩阵形状: {user_item_mat.shape}, 非零元素: {user_item_mat.nnz}") print(f"稀疏度: {1 - user_item_mat.nnz / (user_item_mat.shape[0] * user_item_mat.shape[1]):.4%}")pd.factorize会把字符串ID映射成0到N-1的整数,顺序和原始数据第一次出现的顺序一致,不影响后续计算。构建csr_matrix时要注意:如果原始数据里有重复的(user_id, poi_id)对,scipy默认会把它们的rating值加在一起,而不是取平均。这会造成推荐偏差。所以在构建矩阵前,务必备份原始行数和聚合后的行数做对比,或者显式地先按user_id和poi_id聚合取均值:
df = df.groupby(["u", "p"], as_index=False)["r"].mean()这个坑很多人只有在评估时发现指标异常才回头找。建议把聚合写在编码之后、构建矩阵之前,并在文档的注意项里加一行:"原始交互日志中可能存在同一用户对同一景点的多次点击、多次评分,需要聚合。"
3.2 构建用户-物品矩阵与相似度矩阵的代码
ItemCF的输入是用户-物品矩阵,但要计算物品两两之间的相似度,需要把它转成物品-用户矩阵。这里建议直接用csr_matrix.T做转置,而不是用toarray转成稠密矩阵——在景点数几千、用户数几万的规模下,稠密矩阵的内存占用会轻松超过1GB,而稀疏矩阵只需要存非零元素。
from scipy.sparse import csr_matrix # 用户-物品矩阵转物品-用户矩阵 item_user_mat = user_item_mat.T.tocsr() def cosine_similarity_sparse(mat, min_overlap=3): """ 基于稀疏矩阵的物品相似度计算,避免显式两层循环。 mat: 物品-用户稀疏矩阵,行为物品,列为用户 返回物品相似度矩阵(稀疏) """ mat = mat.astype(np.float32) # 计算每个向量范数 norm = np.sqrt(np.asarray(mat.multiply(mat).sum(axis=1)).ravel()).reshape(-1, 1) norm_inv = 1.0 / (norm + 1e-8) # 用矩阵乘法算两两交集:S = M * M^T,非零位置即共同评分的用户数 inter = mat.dot(mat.T).toarray() # 相似度 = 内积 / (norm_i * norm_j) sim = inter * (norm_inv @ norm_inv.T) # 最小共同评分约束:inter矩阵的对角线是每个物品的评分数, # 两个物品的共同评分数即inter[i,j],但inter[i,j]是共同评分的内积和,不是数量。 # 所以需要另行计算共同评分数。 return sim等一下,这段代码有个逻辑漏洞:inter[i,j]是共同用户的评分内积和,不是共同评分的用户数,用它来施加min_overlap约束不准确。正确做法是构造一个0/1矩阵(有评分则1),再做一次矩阵乘法得到共同评分用户数。修正后的代码如下:
def item_similarity_cosine_sparse(item_user_mat, min_overlap=3): # 0/1矩阵,表示是否有评分 binary_mat = item_user_mat.copy() binary_mat.data = np.ones_like(binary_mat.data) overlap_count = binary_mat.dot(binary_mat.T).toarray() # 共同评分用户数 # 余弦相似度 norm = np.sqrt(np.asarray(item_user_mat.multiply(item_user_mat).sum(axis=1)).ravel()) norm_mul = norm[:, None] * norm[None, :] inner_product = item_user_mat.dot(item_user_mat.T).toarray() # 内积,即分子 sim = inner_product / (norm_mul + 1e-8) # 施加共同评分约束 sim[overlap_count < min_overlap] = 0.0 return sim这才是完整的ItemCF相似度计算。如果你要算皮尔逊相关系数的稀疏版本,需要先对每行centered(减去均值),但centered之后的矩阵就失去稀疏性了,所以业界通常只用余弦。这也是为什么我在第2章建议旅游场景优先用余弦。
3.3 生成推荐列表:预测评分与Top-N排序
有了相似度矩阵,给用户u推荐物品i的分值就是:u评分过的所有物品与i的相似度加权和,权是u对已评分物品的评分。这就是最基础的加权求和预测:
def recommend_for_user(user_id, user_item_mat, sim_matrix, top_n=10): """ user_id: 用户整数ID user_item_mat: 用户-物品稀疏矩阵,行=用户,列=物品 sim_matrix: 物品相似度方阵(稠密或稀疏均可) """ user_row = user_item_mat.getrow(user_id).toarray().ravel() # 用户历史评分 rated_items = np.where(user_row > 0)[0] if len(rated_items) == 0: return [] # 新用户无历史行为,冷启动问题见第5章 # 对每个未评分物品i,计算加权分数 scores = np.zeros(sim_matrix.shape[0]) # 只遍历与已评分物品有相似度的候选 for item in rated_items: sim_row = sim_matrix[item] # 该物品与其他物品的相似度 sim_row[rated_items] = 0 # 排除已评分物品,避免重复推荐 scores += user_row[item] * sim_row # 去掉已经评分过的物品 scores[rated_items] = -np.inf top_items = np.argsort(scores)[::-1][:top_n] return top_items, scores[top_items]这里有一个性能隐患:外层循环遍历用户所有已评分物品,如果用户评分很多(几十个)且物品数很多(几千个),会非常慢。常见优化是把scores的计算向量化:用user_row的非零值乘以相似度矩阵的对应行后求和。但上面的代码可读性好,适合文档初版和技术方案讲解。实际生产环境,建议用以下向量化版本:
def recommend_vectorized(user_row, sim_matrix, exclude_mask, top_n=10): scores = (sim_matrix[exclude_mask == False, :] * user_row[exclude_mask == False]).sum(axis=0) # 简化写法: scores = user_row @ sim_matrix scores[user_row > 0] = -np.inf # 排除已评分 return np.argsort(scores)[::-1][:top_n]向量化思路是:评分向量user_row(1×n)乘以相似度矩阵(n×n),得到每个物品的加权分数,正好是user_row[i]×sim[i][j]对所有i求和。我在文档推荐里会同时给出两种实现,并注明"如果景点数超过5000,请务必用向量化版本"。
3.4 评价指标:Precision、Recall、Coverage的计算代码
推荐做出来,怎么证明它有效?离线评估最常用的指标是Precision@K(推荐出来的景点里用户真正去的比例)和Recall@K(用户真正去的景点里被推荐出来的比例)。Coverage衡量推荐结果对物品库的覆盖程度,防止只推荐热门景点。
def evaluate_recall(reco_lists, test_gt, train_ratings, k=10): """ reco_lists: dict, {user_id: [推荐景点ID]} test_gt: dict, {user_id: set(实际去的景点ID)} train_ratings: 训练集的用户-物品矩阵,用于计算覆盖率 """ hit = 0 recall_sum = 0 precision_sum = 0 all_reco_items = set() for u, recos in reco_lists.items(): gt = test_gt.get(u, set()) if not gt: continue recos_top = recos[:k] hit_items = set(recos_top) & gt hit += len(hit_items) recall_sum += len(hit_items) / len(gt) precision_sum += len(hit_items) / k all_reco_items.update(recos_top) precision = precision_sum / len(reco_lists) recall = recall_sum / len(reco_lists) # 覆盖率:被推荐的物品数 / 总物品数 coverage = len(all_reco_items) / train_ratings.shape[1] return {"precision@k": precision, "recall@k": recall, "coverage": coverage}这个评估函数有几个细节值得关注。一是test_gt要从时间上切分,比如用前90%的交互做训练,后10%做测试,不能随机切分,否则会有数据泄漏。二是如果你推荐的是"去过"的景点,测试集里必须剔除训练集里已出现的景点,否则指标虚高。三是官方文档里如果只给一个prec@10,你要追问它的计算口径:是按用户平均还是按item平均?这里用的是按用户平均,更接近用户视角。
4. 旅游场景特有的坑:稀疏矩阵、流行度偏差和季节因子
4.1 稀疏矩阵下相似度失真,需要加惩罚项
旅游数据的稀疏程度,比电商和视频推荐都夸张。一个普通游客一年可能只去两三个城市、打卡五六个景点,而一个活跃用户可能贡献了几百条交互。这导致大多数物品对的共同评分数量在0到2之间。如果min_overlap设成3,能算相似度的物品对少得可怜;设成1,两个只有一条共同交互的物品就算出相似度1.0,容易把"恰好同一个人去过"当成强相关。
常见的解法是加一个"共同评分数量惩罚"(也叫shrinking factor)。在余弦相似度基础上乘以一个比例:overlap_count / (overlap_count + alpha),alpha是收缩因子,通常取50到100。这样共同评分为1时相似度被压到约1/(1+alpha),共同评分为100时就几乎不衰减。修改后的相似度计算:
def hybrid_similarity(inner_product, norm_mul, overlap_count, alpha=50, min_overlap=1): """ 带收缩因子的余弦相似度,alpha控制惩罚强度,min_overlap过滤噪声。 """ shrink = overlap_count / (overlap_count + alpha) sim = inner_product / (norm_mul + 1e-8) * shrink sim[overlap_count < min_overlap] = 0.0 return simalpha怎么调?一个经验法则是观察相似度分布:如果大多数非零相似度集中在0.8-1.0,说明alpha太小,噪声多;如果集中在0.2以下,说明alpha太大,有效信号被压掉了。我们当时把alpha从0调大到80,推荐结果的Recall下降了5%,但Precision提高了12%,因为推荐列表里噪声少了,用户更愿意点开。所以这个参数不要照抄文档,要拿自己数据做一次小网格搜索。
4.2 热门景点主导推荐,如何用逆用户频率去偏
ItemCF天然倾向推荐热门物品。原因是热门景点出现在大量用户的历史行为里,相似度计算中它们被频繁标记为"相似",导致推荐列表里反复出现故宫、外滩、西湖这些头部景点,长尾景点永远没有曝光。这在旅游推荐里比电商还严重,因为旅游的头部效应更强——全国游客量排前100名的5A景区可能占据了50%的访问量。
去偏的经典方法叫逆用户频率(Inverse User Frequency,IUF),思路与IDF一致:一个物品被越多的用户访问过,它提供的"共性信息"越少,权重越低。把相似度计算中的内积改成赋权后的向量点积:
# 统计每个物品被多少用户访问过 item_user_freq = np.asarray(binary_mat.sum(axis=1)).ravel() # 物品-用户矩阵的每行和 # IUF权重 iuf = np.log(1.0 + total_users / (item_user_freq + 1.0)) # 对物品向量按IUF加权 weighted_mat = item_user_mat.multiply(iuf[:, None]) # 用加权后的矩阵算相似度 sim_iuf = weighted_mat.dot(weighted_mat.T).toarray() # 别忘了除以原始的范数乘积这里有个容易犯错的地方:IUF是对"用户"做加权吗?不,是对"物品"的向量做加权。具体来说,每个物品向量里的非零元素代表该物品被哪些用户评分过,IUF加权是把这个向量整体乘以一个标量,从而抑制高频物品的影响力。如果你的数据里有明确的"用户去景点次数",那么更进阶的做法是把次数做log变换后作为偏好值,效果类似。
加了IUF之后,推荐列表的长尾覆盖率通常能提升15%-30%,但同时Recall会小幅下降。因为热门景点确实有更高的用户命中率。业务上要权衡:如果你们平台的目标是提升小众景点的曝光,IUF必加;如果目标是用户点击率,纯ItemCF可能更稳。文档里建议提供两个版本的相似度矩阵,一个纯余弦、一个IUF加权,线上通过开关切换。
4.3 季节性需求:淡旺季数据要不要分开建模
旅游推荐和电商推荐最大的区别在于强季节性。冬天推荐哈尔滨冰雪世界,夏天推荐青岛海滨浴场;清明假期推荐周边赏花,国庆长假推荐长途目的地。如果你的训练数据是全年混合的,那么"相似景点"关系会被季节因素污染——冬天去过雪乡的用户和夏天去过海边沙滩的用户在季节维度上完全不同,但相似度计算并不在乎季节,它认为"用户去过就代表喜欢"。
我的做法是分两套模型:一套全量训练,用于日常基准推荐;一套按当前季节过滤训练数据,只保留历史上同一季节(前后共3个月)的用户行为。季节模型在旺季的推荐准确率明显比全量模型高,但覆盖率会下降,因为淡季数据少。还有一个折中方案:把"季节"作为相似度计算的一个额外维度嵌入,也就是在计算用户向量之前,对交互时间做sin/cos编码,作为向量的一维特征。这要求评分矩阵变成"值+时间特征"的复合表示,实现复杂度高一些,但如果你的数据里时间戳是完整的,值得投入。
文档里我倾向于推荐最简单的方案:在数据清洗阶段,增加一个season列(12月-2月为0,3月-5月为1,6月-8月为2,9月-11月为3),然后按season切分成四个子数据集,分别训练四个ItemCF模型。线上根据当前日期选择对应的模型。这个方案看起来笨,但胜在稳定、可解释、容易排查问题。当你发现某个季节的推荐明显变差时,可以直接看该季节子数据集的质量,而不是怀疑算法本身。
5. 协同过滤系统的常见问题排查:从数据异常到推荐结果为空
5.1 现象:所有用户推荐结果一模一样
如果你发现不同用户拿到的Top-10推荐列表完全相同,先别急着调算法,去检查数据集里有多少个不同的用户和物品。我曾经排查过一个"推荐全热门"的问题:数据是从日志里抽的,但忘了去掉爬虫流量,导致少数几个"超活跃用户"贡献了80%的交互量,这几个用户的行为模式在矩阵里占据绝对主导,ItemCF算出来几乎所有景点都和他们看过的类似,于是推荐给所有人都是那几个头部景点。
原因:数据质量差,流行度偏差被放大了。
解决:先用分层统计检查交互分布。按用户统计交互总数,画一个长尾图,观察是否存在少数用户贡献了超过30%的交互;如果是,设一个交互上限(比如单个用户最多保留30条行为)或者直接用IUF加权。另一种可能原因是矩阵构建时重复数据没有聚合,导致单条高分记录被不停累加。所以排查的第一步永远是打印df.describe()和交互数分布。
5.2 现象:新用户或新景点完全没有推荐结果
新用户没有历史行为,ItemCF怎么算都算不出分数,返回空列表。新景点没有出现在任何人的历史行为里,相似度矩阵里它的所有元素都是0,永远不会被推荐。这是协同过滤的天然冷启动问题,不是bug。
原因:算法依赖历史交互,新实体没有交互记录。
解决:业务上通常搭配"热门推荐"作为兜底。我在实践中的做法是,在推荐接口的get_recommendations里加一个fallback逻辑:如果算法返回空,就用当季热门Top-N代替。热门列表从最近30天的交互中统计。另一个思路是利用景点本身的属性做内容相似度:比如经纬度距离、景区等级、主题标签(历史/自然/亲子),新景点可以先用属性相似度找到近邻,再通过邻居的协同信号推荐。这一步本质是混合推荐,文档里单独成一节也值,因为它是上线后反馈最多的模块。
5.3 现象:相似度矩阵中出现NaN或负数
运行相似度计算后,打印矩阵发现一堆NaN,或者相似度出现负数。NaN的常见来源是分母为0:某个物品的评分为0(矩阵里全是0),范数为0,除以0得不到合法的浮点数。负数则来自皮尔逊相关系数的计算,因为centered向量可能方向相反。
原因:稀疏矩阵归一化时遇到零向量;评分尺度问题。
解决:在计算范数时加上一个1e-8的极小值,像我在第3章代码里做的那样。同时要过滤掉完全没有评分的物品行——这种情况常见于测试集和训练集切分后,测试集包含了训练集里不存在的新景点。具体做法是在相似度计算前检查:
# 过滤掉所有零行 row_sums = np.asarray(item_user_mat.sum(axis=1)).ravel() nonzero_items = np.where(row_sums > 0)[0] item_user_mat = item_user_mat[nonzero_items]如果你用的是皮尔逊相关系数,还要注意评分均值为0的情况(比如归一化后评分有正有负),此时标准差为0,也会产生NaN。我的建议是:旅游推荐这种稀疏场景,别用皮尔逊,直接用带收缩因子的余弦,省心。
5.4 现象:内存爆掉,相似度矩阵算不出来
物品数5000,相似度矩阵是5000×5000的浮点数组,占100MB,可以接受;但如果物品数是20000,矩阵变成了3.2GB,直接OOM。很多初学者喜欢用pandas.DataFrame来算协方差矩阵,那更是内存杀手。
原因:稠密相似度矩阵随着物品数平方膨胀。
解决:分块计算。把相似度计算改成按批次处理,每次只算一个物品与其他物品的相似度,写入稀疏存储。另外,相似度矩阵稀疏度很高(大部分为0),可以转换成scipy.sparse.coo_matrix或csr_matrix存储,只保存非零相似度。我们在实际项目中,把20000×20000的稠密矩阵转成稀疏矩阵后,内存从3.2GB降到约150MB。另一个思路是ANN索引(近似最近邻)代替精确相似度,用faiss或annoy找每个物品的Top-K近邻,大幅降低存储和线上计算压力。这属于进阶,文档里如果目标是快速跑通,可以先行跳过,但要在文档的扩展阅读里写明这个方向。
5.5 现象:评估时Precision为0.0,前几名的推荐几乎不对
检查测试集的切分方式。很多人用随机抽样把10%的交互作为测试集,但这部分交互可能与训练集中的交互属于同一时间窗口,用户在训练期去过某景点,测试期又去了同一个,推荐出来反而不算命中?其实如果推荐的是同一个景点,应该命中,所以Precision=0更可能是以下原因:测试集中用户真正访问的景点是稀疏的,而推荐列表偏向热门,热门景点与测试集中的个性化景点没有交集。
原因:评估指标与推荐列表的口径不一致,或者推荐列表没有排除训练集中已评分物品。
解决:在生成推荐列表前,先排除用户训练集中所有交互过的物品(正如第3.3节代码中的scores[rated_items] = -np.inf)。如果仍然不行,检查相似度矩阵是否绝大多数元素为0,说明min_overlap设得太大或数据太稀疏,可调小min_overlap再试。这个排查顺序非常有用,能帮你定位到底是评估逻辑错了还是算法效果真差。
6. 文档驱动的落地技巧:把算法包成可复现的离线推荐流程
6.1 用配置文件管理数据路径和超参数
这套算法写完之后,如果你丢给别人一个Python脚本,别人大概率不知道怎么调。文档存在的意义,是把"怎么跑"和"为什么这么跑"说清楚。我在实际交付时会把所有可变参数抽到一个config.yaml里,而不是散落在一堆函数defaults里。
data: raw_behavior: "data/behavior_log.csv" output_dir: "output/" preprocess: action_weights: {click: 0.2, favor: 0.4, score: 0.3, order: 0.1} max_actions_per_user: 30 model: method: "item_cf" similarity: "cosine_shrink" alpha: 60 min_overlap: 3 use_iuf: true season_split: true recommend: top_n: 10 fallback: "hot_seasonal"主程序里用yaml.safe_load(open("config.yaml"))读取,然后传入各个函数。这样做的好处是:你需要对"alpha从60调到80"做实验时,不用改代码,只要改配置并重跑一遍评估脚本即可。文档里还应该附带一份"参数范围说明表",列出每个参数的推荐范围、对结果的影响方向、调试时的观察指标。例如:alpha越大,覆盖率越高但召回越低;min_overlap越大,推荐结果越保守。这种表对新手极其友好。
6.2 用单元测试保护相似度计算函数
推荐系统是典型的"改一处崩全局"。你为了优化性能改了相似度计算的向量化代码,结果精确度和原实现不一致,但线上没报错,直到评估指标漂移你才发现。所以从第一天起,就把核心函数写成可测试的纯函数,并加一个最小化的单元测试。
import unittest import numpy as np class TestSimilarity(unittest.TestCase): def test_cosine_with_min_overlap(self): # 构造一个3x3矩阵,人工算余弦值 mat = np.array([[1, 2, 0], [0, 3, 4], [5, 0, 0]]) sim = cosine_similarity_sparse(csr_matrix(mat), min_overlap=1) # 物品0和物品1的余弦: (1*2+2*3)/(sqrt(5)*sqrt(25)) expect = (2 + 6) / (np.sqrt(5) * np.sqrt(25)) self.assertAlmostEqual(sim[0, 1], expect, places=5) # 物品0和物品2的共同评分为0,余弦应为0 self.assertEqual(sim[0, 2], 0.0)把这种测试塞进CI里,每次改动后跑一遍,能拦住大部分回归问题。尤其是你后来想把纯Python循环换成numpy加速、再换成sparse dot时,这个测试能确保数学逻辑不变。
6.3 结果验证:用回访数据做A/B测试前的离线模拟
离线评估能证明算法在历史数据上表现尚可,但真正上线前最好做一次"回放验证"。方法是拿最近一段时间的用户真实选择作为"伪线上":假设系统在时刻T给用户推荐了10个景点,用户之后一周是否点击了其中之一。如果点击了,计一次命中。这个实验不需要开发线上系统,只需在日志数据里模拟时间窗口。我常用代码是:
def replay_evaluate(train_df, test_df, reco_func, time_col="ts", response_window=7): """ train_df: 截止T时刻的训练数据 test_df: T之后窗口内的交互数据 reco_func: 接收train_df,返回{user_id: [poi_ids]} """ recos = reco_func(train_df) hits = 0 total = 0 for user, gt_pois in test_df.groupby("user_id"): gt_set = set(gt_pois["poi_id"]) if user not in recos or not gt_set: continue total += 1 if set(recos[user]) & gt_set: hits += 1 return hits / max(total, 1)回放验证的意义在于,它能反映"如果当时上了这套推荐,用户会不会接受"这一真实场景,而普通离线评估只计算"推荐和已知行为的重叠度",容易高估效果。我的经验是,离线Precision为0.3的系统,回放验证命中率往往只有0.15左右,因为用户的行为很多是突发的、非兴趣驱动的。回放指标更接近线上A/B测试结果。
最后一条落地技巧:把这份Python协同过滤旅游推荐系统文档写成一份内部Wiki,包含数据说明、启动步骤、参数表、排错清单和回放脚本。团队的后续成员接手时,花一个上午就能跑通全流程,碰到第5章里的五个经典问题时知道去哪里找答案,而不是重复造轮子。这也是我从"写代码的人"变成"写系统的人"的关键一步——文档不只是在解释代码,它把做推荐时的判断依据、踩坑记录和参数边界沉淀为团队资产。希望这份梳理对你有帮助,照着做,你也能在旅游推荐这个场景里快速产出一个可信、可改、可评估的协同过滤基线系统。
本文还有配套的精品资源,点击获取