简介智能旅行推荐系统完整项目包面向推荐系统、数据挖掘及机器学习方向的开发者、研究者和毕业设计学生。项目围绕用户的旅行详细信息——目的地、预算、起止日期以及景点类别、酒店设施、美食类型等个性化偏好构建个性化旅行推荐方案覆盖数据预处理、偏好建模、推荐生成等关键环节可作为课程设计或企业原型参考。包内共197个文件压缩包约135.97MB其中数据文件如CSV、JSON、Parquet等提供酒店信息、评论及用户未浏览物品数据Python脚本与Jupyter Notebook用于数据分析与算法实现图片文件展示可视化结果说明文档则便于快速了解项目结构目录清晰、便于按需查阅。已有206人浏览学习。借助该资源读者可学习数据去重、用户未看物品处理、酒店特征整合等具体做法理解从原始数据到推荐结果的完整流程并在此基础上复用代码快速搭建自己的智能旅行推荐实验环境。1. 为什么旅行推荐系统先要解决“冷启动”和“数据去重”打开这个项目的压缩包你会看到一堆 CSV 文件reviews_dedup.csv、hotel_info.csv、hotel_info_dedup.csv还有一连串重复的user1_unseen.csv。文件名里的_dedup后缀已经透露了关键信息——这不是一个“从零造推荐模型”的 Demo而是一个被真实数据源折磨过的工程现场。用户提供的是目的地、预算、出发和结束日期以及景点类别、酒店设施、美食类型这几个维度但原始数据里往往同一个酒店出现多条记录、同一条评论被导出了多次。如果不在特征工程之前做去重和标准化后面算出来的相似度矩阵、协同过滤评分都会被重复计数带偏。这个系统的适用场景很明确要么是旅游平台给注册用户做“行程规划 酒店/景点推荐”要么是离线分析场景里用历史用户数据预测新用户的偏好。对于有 5 年经验的后端或算法工程师重点不在于理解“什么是推荐”而在于处理用户自定义约束日期、预算和静态物品属性酒店设施、美食标签时的数据结构设计。下面我从特征工程开始把一条能跑通的推荐管线拆开讲。2. 输入特征工程目的地、预算、日期、偏好如何转化为推荐向量原始的用户输入通常不是规范的张量而是类似“去杭州两人预算五千七月中旬喜欢民宿和清淡菜”这样的自然语言或表单字段。推荐系统不直接吃字符串第一步要做的是把行程约束和偏好映射成可计算的向量。2.1 用户画像与行程参数的实体化我一般先建一个user_profile表把用户 ID、常住地、历史浏览过的景点类型、点过赞的酒店设施标签存成 JSON 或逗号分隔字符串。这个项目的reviews_dedup.csv正是用来干这个的——每条用户评论里含有对应酒店 ID、评分和评论内容从中可以抽取用户对设施和美食的隐含偏好。关键点在于去重逻辑同一个user_id hotel_id review_date只保留一条因为用户几乎不会在同一时间对同一酒店写两条有效评论。常见做法是用drop_duplicates指定子集而不是全字段去重否则时间字段微小的差异会让重复记录继续残留。import pandas as pd reviews pd.read_csv(reviews_dedup.csv) # 注意这里假设原始文件里存在这几列实际以你的数据为准 reviews reviews.drop_duplicates(subset[user_id, hotel_id, review_date]) reviews reviews.sort_values(review_date, ascendingFalse) # 提取用户最近一年的行为作为画像依据 recent reviews[reviews[review_date] 2023-01-01] user_pref recent.groupby(user_id).agg( avg_score(rating, mean), like_hotels(hotel_id, lambda x: list(x)[:20]), visit_count(hotel_id, count) ).reset_index()这段代码的逻辑是先按用户、酒店、评论日期去重再排序然后用最近一年的数据聚合出用户的平均评分和历史入住酒店列表。like_hotels后面可以用来对比酒店之间的相似度visit_count则作为用户活跃度的权重。如果你手里的reviews_dedup.csv没有review_date可以直接去掉排序和日期筛选但风险是早期行为会污染“最近偏好”。2.2 偏好编码景点类别、酒店设施、美食类型的多热编码景点类别历史、文化、自然、冒险、酒店设施健身房、泳池、免费 Wi-Fi、美食类型素食、海鲜、当地特色都是多选标签不能做普通 one-hot因为一个用户可能同时喜欢三种美食。我采用多热编码Multi-hot把每个用户的偏好列表变成一个固定长度的二进制向量向量的每一位对应一个预定义的标签。标签集合需要先统计出来假设hotel_info_dedup.csv里有facilities列review_dedup里有food_tags列。先构造标签字典再批量编码import numpy as np def multi_hot_encoder(tag_list, all_tags): # 初始化全零向量 vec np.zeros(len(all_tags), dtypenp.float32) tag2idx {t: i for i, t in enumerate(all_tags)} for t in tag_list: # 如果是字符串则按分隔符拆分 if isinstance(t, str): for x in t.split(,): x x.strip() if x in tag2idx: vec[tag2idx[x]] 1.0 return vec # 从数据里收集全部标签 all_facilities set() for v in hotel_info_dedup[facilities].dropna(): all_facilities.update([x.strip() for x in v.split(,)]) all_facilities sorted(all_facilities) # 对每个用户构建偏好向量这里假设 user_food 是用户美食偏好列 user_vecs [] for uid, row in user_pref.iterrows(): pref_tags row.get(food_prefs, ) user_vecs.append(multi_hot_encoder([pref_tags], all_facilities))这里有个容易被忽略的坑all_facilities如果直接用所有出现过的设施维度会高达几百而且很多低频设施对推荐没有区分度。我一般只保留出现次数占比超过 1% 的设施标签把长尾标签统一归到other类否则后续矩阵分解会引入大量噪声。2.3 时间段特征提取与预算归一化旅行开始和结束日期不能直接喂给模型需要转换成三个量总天数(end - start).days、是否跨周末、是否在旺季七八月或法定假日。预算则要根据同行人数做归一化变成“人均每日预算”这样才可以在不同用户之间比较。from datetime import datetime def date_feature(start_str, end_str, budget, persons): start datetime.strptime(start_str, %Y-%m-%d) end datetime.strptime(end_str, %Y-%m-%d) days (end - start).days 1 is_weekend int(start.weekday() 4 or end.weekday() 4) is_peak int(start.month in [7, 8] or (start.month 9 and start.day 3)) # 人均每日预算加 1 避免除零 per_day_budget budget / (max(persons, 1) * max(days, 1)) return days, is_weekend, is_peak, round(per_day_budget, 2) # 假设用户输入 DataFrame 里有这些字段 users pd.DataFrame([ {uid: u1, start: 2024-07-15, end: 2024-07-20, budget: 5000, persons: 2}, {uid: u2, start: 2024-09-10, end: 2024-09-12, budget: 3000, persons: 1}, ]) users[[trip_days, has_weekend, is_peak, daily_budget_per_person]] \ users.apply(lambda r: pd.Series(date_feature(r[start], r[end], r[budget], r[persons])), axis1)旺季判断写得很朴素实际项目中我一般外部引入一份节假日表避免把工作日输出误判为便宜时段。daily_budget_per_person要参与后面的召回过滤不是特征直接进模型如果酒店均价高于该值的 1.5 倍直接排除因为用户对预算的敏感度往往是非线性的——便宜 10% 可能无所谓贵 50% 就一定不接受。3. 推荐算法选型与实现从协同过滤到混合推荐的落地代码当输入特征准备好之后就要决定用什么算法。这个项目的数据量级属于“中等稀疏”用户数不多但酒店和评论条目可能上万。纯协同过滤会遇到严重的数据稀疏——一个用户可能只住过 3 家酒店算出来的相似度矩阵基本是空壳。我在这里使用「基于物品的协同过滤 基于内容的候选过滤」混合策略先把候选限定在预算、日期、偏好都满足的范围里再用协同过滤对候选做排序。3.1 物品-用户矩阵构建与相似度计算物品酒店之间的相似度可以用“哪些用户同时喜欢它们”来计算。对于每个酒店统计它获得的评分构成一个酒店-用户稀疏矩阵。使用余弦相似度计算物品相似度但为了控制计算量只保留每个酒店 Top 30 个近邻。from scipy.sparse import csr_matrix from sklearn.metrics.pairwise import cosine_similarity # 从 reviews 构造物品-用户评分矩阵 hotel_users reviews.groupby([hotel_id, user_id])[rating].mean().reset_index() hotel_list hotel_users[hotel_id].astype(category) user_list hotel_users[user_id].astype(category) row hotel_list.cat.codes.values col user_list.cat.codes.values data hotel_users[rating].values matrix csr_matrix((data, (row, col)), shape(len(hotel_list.categories), len(user_list.categories))) # 注意这里用酒店做行用户做列 item_sim cosine_similarity(matrix.T) # 等价于计算酒店之间的余弦相似度 # 保留每个酒店的 top 30 近邻 top_k 30 neighbors {} for i in range(item_sim.shape[0]): sim_scores item_sim[i] # 排除自身 sim_scores[i] 0 top_indices np.argsort(sim_scores)[::-1][:top_k] neighbors[hotel_list.categories[i]] [(hotel_list.categories[j], sim_scores[j]) for j in top_indices if sim_scores[j] 0.1]这段代码里最关键的是matrix.T的位置由于原始矩阵形状是酒店×用户转置之后再算cosine_similarity得到的是酒店-酒店相似度。如果你把矩阵构造反了算出来的就是用户相似度推荐逻辑会完全错乱。sim_scores[i] 0是为了排除自己否则每个酒店都和自己相似度最高。3.2 基于内容的召回利用用户显式偏好过滤候选集协同过滤负责“猜你喜欢”但有一个前提候选集合不能太大。如果用户明确说“预算人均每天 500不要旺季”你就不该把山顶海景别墅推给他。基于内容的召回先用硬条件过滤再用标签匹配做软过滤。def content_recall(user_row, hotel_df, facility_vec, price_colprice_per_night): # 第一步候选必须满足预算和天数 max_price user_row[daily_budget_per_person] * 1.5 mask (hotel_df[price_col] max_price) \ (hotel_df[available_from] user_row[start]) \ (hotel_df[available_to] user_row[end]) candidates hotel_df[mask].copy() if len(candidates) 0: return pd.DataFrame() # 没有达标酒店 # 第二步计算用户偏好向量和酒店设施向量的匹配度 user_vec multi_hot_encoder(user_row.get(food_prefs, ), all_facilities) hotel_vecs np.vstack([multi_hot_encoder(hf, all_facilities) for hf in candidates[facilities]]) match_score hotel_vecs user_vec # 点积得到重叠标签数 candidates[content_score] match_score / np.maximum(user_vec.sum(), 1) return candidates.sort_values(content_score, ascendingFalse)这里的content_score是“用户偏好标签命中率”如果用户偏好 5 个标签酒店命中 3 个得分 0.6。这个分数会作为混合排序的一个因子而不是唯一依据——因为用户可能喜欢“泳池 免费停车”但酒店只有泳池也值得被推荐只是排后面点。3.3 混合加权与 Top-K 排序混合推荐的核心是如何把协同过滤得分和内容得分拉到同一个量纲。协同过滤分数通常是相似度加权和范围在 0-1 之间内容得分也是 0-1。简单相加权重可能不会平衡需要做 min-max 归一化。我常用的组合函数是def hybrid_sort(user_id, recall_candidates, neighbors, user_profile): scores {} base_score 3.0 # 默认基础分避免冷启动用户全部为 0 for _, hotel in recall_candidates.iterrows(): hotel_id hotel[hotel_id] # 协同过滤部分该用户对候选酒店近邻的加权评分 cf_score 0.0 if hotel_id in neighbors: for nid, sim in neighbors[hotel_id]: # 找到用户对近邻酒店的评分 user_rating user_profile.loc[(user_profile[user_id] user_id) (user_profile[hotel_id] nid), rating] if len(user_rating) 0: cf_score sim * (user_rating.values[0] / 5.0) # 归一化到0-1 # 内容得分归一化 cs hotel[content_score] # 最终权重内容0.6协同0.4对没有协同数据的用户全权交给内容 final 0.6 * cs 0.4 * min(cf_score / 5.0, 1.0) base_score / 5.0 scores[hotel_id] final # 返回排序后的酒店ID列表 return sorted(scores.items(), keylambda x: x[1], reverseTrue)这个混合公式里base_score给出了一个平滑底数让用户即使没有历史行为也会得到一个和内容相关的分数。min(cf_score / 5.0, 1.0)防止某家酒店近邻过多时评分数值溢出。实际调整权重时我通常先跑一遍 baseline观察内容评分或者协同评分哪个和用户回访的相关性更高再向那个方向倾斜。4. 基于酒店和评论数据的召回排序Pandas 数据清洗与特征拼装前面的算法假设你已经有了干净的hotel_df和user_profile但真正的工程耗时 80% 在构造这些 DataFrame。项目里特意给出了hotel_info.csv和hotel_info_dedup.csv这正是为了测试你对“去重后数据”和“原始数据”差异的敏感度。4.1 多表去重与关联reviews_dedup.csv 和 hotel_info_dedup.csvhotel_info.csv往往存在同一个酒店 ID 对应多条记录原因是酒店信息在多个批次导入时产生了部分字段更新。比如第一次导入只有房价第二次补全了设施。如果直接 groupby 然后取均值价格会失真正确做法是按主键hotel_id和某个时间戳列保留最新一条或者用fillna向前填充缺失字段。hotel_raw pd.read_csv(hotel_info.csv) # 假设有 load_date 列代表信息更新时间 hotel_dedup hotel_raw.sort_values(load_date, ascendingFalse) \ .drop_duplicates(hotel_id, keepfirst) \ .reset_index(dropTrue) # 检查去重效果 print(f原记录数: {len(hotel_raw)}, 去重后: {len(hotel_dedup)}) # 如果 hotel_info_dedup.csv 是官方给好的版本可以先对比字段差异 hotel_final pd.read_csv(hotel_info_dedup.csv) missing_fields set(hotel_dedup.columns) - set(hotel_final.columns)如果发现官方hotel_info_dedup.csv里没有你需要的available_from和available_to不要慌这两个字段通常可以从行程数据或者酒店页面的淡旺季配置扩展出来。更实用的做法是从reviews_dedup.csv中聚合出每个酒店的平均评分和评论总数作为后面排序的特征hotel_stats reviews.groupby(hotel_id).agg( avg_rating(rating, mean), review_count(rating, count), latest_review_date(review_date, max) ).reset_index() hotel_feat hotel_final.merge(hotel_stats, onhotel_id, howleft) # 缺失评分填充平均值 hotel_feat[avg_rating] hotel_feat[avg_rating].fillna(hotel_feat[avg_rating].mean())avg_rating和review_count要区分对待评论数少的酒店即使评分 5.0 也不是可靠信号。我通常会在排序阶段给评论数加一个对数权重例如final_score avg_rating * np.log1p(review_count)抑制小样本噪音。4.2 文本评论的情感分数作为额外信号reviews_dedup.csv里的评论文本不能浪费。虽然两个 CSV 都没有明确的情感标注但可以用简单的词典法或预训练模型给每条评论打一个情感分。考虑到项目体积我建议用 VADER 这类轻量工具而不是重模型。但这里有一个陷阱VADER 对中文支持不好如果你的评论是中文需要用 SnowNLP 或自带的情感词典。# 以英文评论为例 from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer analyzer SentimentIntensityAnalyzer() def sentiment_score(text): if not isinstance(text, str) or len(text) 5: return 0.0 vs analyzer.polarity_scores(text) return vs[compound] # 范围 -1 到 1 reviews[sentiment] reviews[review_text].apply(sentiment_score) # 聚合到酒店 hotel_sentiment reviews.groupby(hotel_id)[sentiment].mean().reset_index() hotel_feat hotel_feat.merge(hotel_sentiment, onhotel_id, howleft) hotel_feat[sentiment] hotel_feat[sentiment].fillna(0)注意compound分数对短中性文本会偏向 0如果评论只有“不错”两个字会被判成 0这没问题。但如果一条评论全是表情符号VADER 也可能给分你得想好是否剔除这种非文字记录。这里建议另外一个清洗规则过滤掉长度小于 5 的评论因为它们的信息量不足以影响推荐排序。4.3 可解释性输出给用户看“为什么推荐这家”推荐系统的口碑往往系于解释性。你可以从两方面给出理由一是“你喜欢的设施在这里都有”比如用户选了“免费 Wi-Fi 泳池”而这家酒店恰好具备二是“和你口味相似的用户也去了这里”。实现并不复杂只需要在推荐结果里带上命中标签和相似用户数量。def explain_hotel(hotel_row, user_pref_tags, neighbor_hosts): hit list(set(hotel_row[facilities].split(,)) set(user_pref_tags)) reason [] if hit: reason.append(f包含你需要的{, .join(hit)}) if neighbor_hosts: reason.append(f{len(neighbor_hosts)} 位偏好相似的用户曾预订) if hotel_row[sentiment] 0.3: reason.append(近期评论情绪积极) return .join(reason) if reason else 综合评分较高返回的字符串可以直接放在前端卡片上。注意不要过度承诺比如能用“评分高”就不要用“最优选择”用户在旅行决策场景对确定性描述更敏感。将解释结果存成单独一列每次推荐请求生成后缓存起来避免重复计算。5. 评估与调参技巧用 user1_unseen 测试集做离线验证的完整流程user1_unseen.csv这个文件名很有意思——unseen表示用户 1 未交互过的酒店。它天然是一个验证集训练阶段把用户 1 的历史记录剔除推荐模型跑一遍看模型输出的 Top-K 里有多少是出现在unseen中的。如果推荐的酒店用户根本没看过说明模型在探索而不是在重复用户已有行为但如果推荐的全是unseen里完全无关的高价酒店召回精度也不会高。5.1 构建用户维度的训练/测试切分不要用随机行切分那样同一个酒店可能训练和测试都出现导致评估虚高。正确做法是按用户切分把选定用户的所有历史交互留出作为 ground truth再用剩下用户训练相似度矩阵。def leave_one_user_check(train_users, test_users, all_reviews): train_df all_reviews[all_reviews[user_id].isin(train_users)] test_user_id test_users[0] # 测试用户的真实交互即 unseen 列表中的酒店 test_interactions all_reviews[all_reviews[user_id] test_user_id] test_positives set(test_interactions[hotel_id].unique()) # 用训练集构造物品相似度训练时不包含测试用户 train_matrix build_item_user_matrix(train_df) item_sim compute_similarity(train_matrix) # 对测试用户做推荐 recommendations hybrid_recommend(user_profile, item_sim, k10) rec_ids {hotel_id for hotel_id, _ in recommendations} # 计算命中率 hits len(rec_ids test_positives) precision hits / len(rec_ids) recall hits / len(test_positives) return precision, recall这里把precision10和recall打印出来是在离线阶段判断推荐是否合理的第一步。user1_unseen.csv里可能有几十个酒店如果你的推荐 Top-10 一条都没有命中就应该回头检查特征构造和相似度计算而不是急着调权重。5.2 指标PrecisionK 与个性化程度PrecisionK 衡量推荐准确度但旅行推荐里同样重要的还有个性和多样性。如果系统给所有用户都推同一家热门酒店precision 可能还不低因为人人都喜欢热门。但这对用户价值不大。我自己会加一个“个性化程度”指标计算两个随机用户的推荐列表 Jaccard 相似度再取平均。理想值应该在 0.1~0.3 之间。def jaccard_similarity(list_a, list_b): set_a, set_b set(list_a), set(list_b) return len(set_a set_b) / len(set_a | set_b) # 随机抽 50 对用户计算平均 Jaccard from itertools import combinations users_sampled random.sample(valid_user_ids, 100) sims [] for u1, u2 in combinations(users_sampled, 2): rec_a get_recommendations(u1, top_k10) rec_b get_recommendations(u2, top_k10) sims.append(jaccard_similarity(rec_a, rec_b)) print(平均个性化 Jaccard 相似度:, np.mean(sims))如果 Jaccard 超过 0.6说明你的排序模型几乎没有区分用户偏好问题大概率出在content_score权重太低——用户在候选集的差异被整体平均分主导了。5.3 参数敏感性检查和实际部署注意点最后分享一个调参技巧不要只看最终指标要看指标随参数变化的曲线。比如把内容权重从 0.1 到 0.9 每 0.1 扫一遍记录 Precision10 和 Jaccard画成两条曲线选择“准确度不过低、多样性可接受”的交叉区域。这个区域通常在 0.5-0.7 之间具体取决于你的数据稀疏度。weight_curve [] for w in np.arange(0.1, 1.0, 0.1): prec, rec, div evaluate_with_weight(w) weight_curve.append((w, prec, rec, div)) best_row max(weight_curve, keylambda x: x[1] * 0.6 x[3] * 0.4) print(f推荐权重: {best_row[0]:.1f}, Precision10: {best_row[1]:.3f}, Jaccard: {best_row[3]:.3f})部署时最容易被遗忘的是推荐结果缓存。用户输入的start,end,budget变化都会导致重新计算但大多数用户在一天内不会反复改行程所以可以按user_id 日期 预算区间作为缓存 keyTTL 设为 1 小时。这样线上高峰期 QPS 能抗住也不会因为酒店价格微调而频繁刷新推荐列表。另外user1_unseen这类文件在线上环境并不存在它只是离线验证时的遗留下游数据——千万不要在生产代码里对它做任何依赖否则冷启动用户会拿不到推荐。本文还有配套的精品资源点击获取