简介:智能旅行推荐系统完整项目包,面向推荐系统、数据挖掘及机器学习方向的开发者、研究者和毕业设计学生。项目围绕用户的旅行详细信息——目的地、预算、起止日期,以及景点类别、酒店设施、美食类型等个性化偏好,构建个性化旅行推荐方案,覆盖数据预处理、偏好建模、推荐生成等关键环节,可作为课程设计或企业原型参考。包内共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', ascending=False) # 提取用户最近一年的行为作为画像依据 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), dtype=np.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'])), axis=1)旺季判断写得很朴素,实际项目中我一般外部引入一份节假日表,避免把工作日输出误判为便宜时段。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_col='price_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', ascending=False)这里的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(), key=lambda x: x[1], reverse=True)这个混合公式里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.csv
hotel_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', ascending=False) \ .drop_duplicates('hotel_id', keep='first') \ .reset_index(drop=True) # 检查去重效果 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, on='hotel_id', how='left') # 缺失评分填充平均值 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, on='hotel_id', how='left') 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, k=10) 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这里把precision@10和recall打印出来,是在离线阶段判断推荐是否合理的第一步。user1_unseen.csv里可能有几十个酒店,如果你的推荐 Top-10 一条都没有命中,就应该回头检查特征构造和相似度计算,而不是急着调权重。
5.2 指标:Precision@K 与个性化程度
Precision@K 衡量推荐准确度,但旅行推荐里同样重要的还有个性和多样性。如果系统给所有用户都推同一家热门酒店,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_k=10) rec_b = get_recommendations(u2, top_k=10) 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 扫一遍,记录 Precision@10 和 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, key=lambda x: x[1] * 0.6 + x[3] * 0.4) print(f"推荐权重: {best_row[0]:.1f}, Precision@10: {best_row[1]:.3f}, Jaccard: {best_row[3]:.3f}")部署时最容易被遗忘的是推荐结果缓存。用户输入的start,end,budget变化都会导致重新计算,但大多数用户在一天内不会反复改行程,所以可以按user_id + 日期 + 预算区间作为缓存 key,TTL 设为 1 小时。这样线上高峰期 QPS 能抗住,也不会因为酒店价格微调而频繁刷新推荐列表。另外,user1_unseen这类文件在线上环境并不存在,它只是离线验证时的遗留下游数据——千万不要在生产代码里对它做任何依赖,否则冷启动用户会拿不到推荐。
本文还有配套的精品资源,点击获取