ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python的酒店推荐系统实战:从协同过滤到混合推荐

基于Python的酒店推荐系统实战:从协同过滤到混合推荐 1. 先把问题说清楚酒店推荐系统到底在推荐什么做酒店推荐系统和做电商推荐、内容推荐最大的区别就落在决策成本和不可退换这两个词上。买件衣服不合适可以退刷到一条短视频不好看可以划走但用户订酒店是拿真金白银和行程安排做赌注——位置偏了、卫生不行、和图片严重不符这种体验损失是没法一键撤销的。所以基于Python做酒店推荐系统第一步不是急着写算法而是搞清楚你要在哪个环节、给什么样的用户、解决什么样的问题。1.1 三种典型的酒店推荐场景对应三种不同的建模思路我在实际项目中遇到过三种比较典型的酒店推荐诉求OTA平台的全站推荐用户在首页、酒店列表页、订单完成后的猜你喜欢位看到推荐。这类场景特点是流量大、用户行为稀疏、意图模糊推荐重点是把可能感兴趣的候选池拉起来本质上是个召回问题。以城市为中心的定向推荐用户输入目的地杭州、上海虹桥商圈之后系统在结果页内做排序优化。这类场景用户意图很明确需求已经从去哪住收敛到在这里住哪家推荐算法要解决的是怎么把位置、价格、评分、设施这些信息组合成合理的排序本质上是个精排问题。差旅/团队场景下的标准化推荐企业差旅用户可能更看重协议价、报销范围、距离公司办事地点的远近。这类场景数据量不大但规则属性强往往要用混合策略而不是纯靠行为数据。我当时做的版本定位是第一种和第二种的结合——先用召回策略缩小候选集再用排序模型调整顺序。这种定位最贴近实际业务也最容易把推荐系统的链路完整跑起来。1.2 数据从哪来公开数据集、爬虫自采还是程序化生成酒店推荐系统的数据和电商还不太一样。公开的、带真实用户行为的酒店数据最著名的就是Expedia曾经发布过一个大规模的点击/预订数据集大多数学术论文用的就是它另外TripAdvisor也有评论数据可下载。不过对大多数做课程设计、毕设或者自研Demo的开发者来说那个数据集体积太大光清洗就要花不少时间。实际做下来我建议按下面三种方式选一种公开学术数据集适合要做对比实验、写论文的场景。数据真实行为字段齐全但需要花时间理解字段含义而且数据偏老。自研爬虫采集适合目标城市明确、想要最新数据的场景。但爬OTA平台要注意合规风险而且酒店信息页结构复杂、反爬措施多投入产出比不划算。程序化生成模拟数据适合先把推荐链路跑通、验证算法逻辑的场景。自己生成200个用户、500家酒店、几千条行为记录字段完全可控算法验证足够用。我自己第一次落地用的是第三种。原因很简单推荐系统的核心链路是数据→特征→召回→排序→评估算法本身才是关键模拟数据够用。千万别一上来就去搞爬虫数据合规问题和清洗成本会拖垮整个项目节奏。1.3 生成模拟数据的一个可复用方案如果你想快速验证算法逻辑可以用Python写一个简单的数据生成器核心逻辑是给每个用户设定偏好画像价格区间偏好、星级偏好、商圈偏好再给每家酒店设定属性标签然后基于偏好匹配概率决定用户是否产生行为。这样生成的数据自带可被推荐的规律算法跑出来的效果才看得出来。import random import pandas as pd random.seed(42) # 模拟用户画像 users [] for uid in range(1, 201): users.append({ user_id: uid, pref_price_low: random.choice([0, 150, 300, 500]), pref_price_high: random.choice([300, 500, 800, 1200]), pref_star: random.randint(2, 5), pref_biz_district: random.choice([市中心, 机场, 火车站, 会展中心, 大学城]) }) # 模拟酒店属性 hotels [] for hid in range(1, 501): hotels.append({ hotel_id: hid, price: random.randint(120, 1000), star: random.randint(2, 5), biz_district: random.choice([市中心, 机场, 火车站, 会展中心, 大学城]), score: round(random.uniform(3.5, 5.0), 1), facility_score: round(random.uniform(2.0, 5.0), 1) }) # 模拟行为匹配度高就更容易产生浏览/预订 behaviors [] for user in users: for hotel in hotels: hit_price user[pref_price_low] hotel[price] user[pref_price_high] hit_star abs(user[pref_star] - hotel[star]) 1 hit_district user[pref_biz_district] hotel[biz_district] match_score (hit_price * 0.4 hit_star * 0.3 hit_district * 0.3) if match_score 0.4 and random.random() match_score: behaviors.append({ user_id: user[user_id], hotel_id: hotel[hotel_id], action: booking if random.random() 0.25 else click, rating: round(random.uniform(3.0, 5.0), 1) })生成完数据后有两件事必须做一是看行为数据的稀疏度。如果每位用户平均行为数少于10条协同过滤基本没法用需要退回到基于内容的路线上二是确认行为分布不是完全均匀的否则热门酒店这一路召回策略就没有存在意义。这两个检查会直接决定后面算法选型的方向省不掉。2. 算法选型的真实决策过程我为什么没有一上来就上深度学习很多人一看到推荐系统四个字第一反应就是WideDeep、DeepFM、图神经网络。这个想法在酒店推荐场景里大概率会翻车。原因有两层第一绝大多数酒店行为数据是高度稀疏的一家城市可能就几百家酒店一个用户一年也就订几次房这种数据量撑不起复杂模型的训练第二酒店推荐是一个强解释性的场景用户想知道为什么给我推这家规则和相似度逻辑更容易解释、更容易调优。我最终采用的是协同过滤为主基于内容为辅规则用于冷启动的三层结构。这个判断不是凭空来的逻辑链是协同过滤在用户行为相对丰富时效果好能挖掘出用户之间的隐式偏好但酒店领域长尾用户和长尾酒店都很多新用户和新酒店必须靠内容和规则兜底把三层结合起来才能保证不同生命周期阶段都有可用的推荐结果。2.1 基于用户的协同过滤UserCF在酒店场景中的适配性用户协同过滤的核心是和你偏好相似的人订了什么酒店你也可能喜欢。在酒店场景里这个假设是成立的尤其对于商务客群——经常去同一个城市、偏好同一个商圈、保持同一个价位习惯的用户群体他们的选择参考价值很高。相似度计算我推荐用余弦相似度加皮尔逊相关系数结合验证。原始评分矩阵如下import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity # 构造 user-item 评分矩阵 rating_pivot behaviors.pivot_table( indexuser_id, columnshotel_id, valuesrating ).fillna(0) # 计算用户间余弦相似度 user_sim cosine_similarity(rating_pivot) user_sim_df pd.DataFrame( user_sim, indexrating_pivot.index, columnsrating_pivot.index )注意这里的两个关键点。第一fillna(0)是给矩阵填充用的但对余弦相似度来说全是0的稀疏矩阵会导致相似度普遍偏低且区分度差。我后面的经验是只保留至少有5~10个行为记录的用户做相似度计算不然冷启动用户的相似没有任何统计意义。第二评分的数值不一定只靠显式评分点击行为可以记为较低的隐式反馈值预订可以记为高值这样比只有0/1的交互矩阵信息量更大协同过滤效果也更稳。2.2 基于物品的协同过滤ItemCF为什么更适合酒店列表页如果你在用户已经搜索某城市、浏览酒店列表的场景下做推荐ItemCF会比UserCF更顺手。原因很直观用户的兴趣在特定上下文里是收敛的他看到一家亚朵觉得不错你给他推同一商圈价位接近的亚朵或全季转化概率远大于推荐一个相似用户订过的其他城市酒店。这在推荐系统术语里叫I2I关联本质是分析物品间的共现关系。物品相似度基于用户行为的共现矩阵计算。直接用公式from sklearn.metrics.pairwise import cosine_similarity # 转置矩阵行是酒店列是用户 hotel_sim cosine_similarity(rating_pivot.T) hotel_sim_df pd.DataFrame( hotel_sim, indexrating_pivot.columns, columnsrating_pivot.columns ) def recommend_by_item(user_id, top_n10): user_rated rating_pivot.loc[user_id] rated_hotels user_rated[user_rated 0].index.tolist() scores {} for hotel in rated_hotels: sim_list hotel_sim_df[hotel].sort_values(ascendingFalse)[1:20] for candidate, sim in sim_list.items(): if candidate in rated_hotels: continue scores[candidate] scores.get(candidate, 0) sim * user_rated[hotel] return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n]这段代码里有个细节候选物品的打分是相似度乘以用户对原物品的评分累加的所以用户如果给某个酒店打了高分那它相似酒店的得分也会被抬高这就是加权投票的思想。跑通之后你会发现一个问题ItemCF很容易推荐热门酒店因为热门酒店出现的共现次数高。这点不必急着解决在召回阶段它能保证覆盖率但精排阶段要加上多样性和新颖性的惩罚项否则推荐列表会显得很无聊。2.3 基于内容的推荐是冷启动的真正解药酒店和电影最大的不同在于酒店属性信息非常结构化——价格、星级、商圈、评分、设施、品牌这些标签都现成。所以基于内容的路子在酒店推荐里不应该是辅助而是主干之一。利用tf-idf向量化酒店标签再计算酒店间的文本相似度这个方案可以把那些没有行为历史但属性接近用户偏好的新酒店顺利推出来。我处理的方案是把酒店的文本属性商圈、品牌、设施标签做分词后拼成一个文本字段然后用TfidfVectorizer计算向量距离。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import linear_kernel hotels[tags] ( hotels[biz_district] hotels[star].astype(str) 星 hotels[facility_level] ) tfidf TfidfVectorizer(token_patternr(?u)\b\w\b) hotel_vectors tfidf.fit_transform(hotels[tags]) content_sim linear_kernel(hotel_vectors, hotel_vectors)linear_kernel其实就是没做归一化的内积在tf-idf向量已经做过l2归一化的情况下线性核就是余弦相似度计算效率比显式调用cosine_similarity高不少数据量上来以后这个差异非常明显。内容推荐的召回逻辑是取用户最近浏览或预订过的酒店作为种子找内容相似度最高的N家酒店做候选。这个方案有两层意义——不仅解决了新游客进入平台没有行为历史的冷启动还能在协同过滤结果因为数据稀疏而抖动时提供一个稳定兜底的推荐路径。2.4 三层策略融合的问题不是所有推荐结果都适合平均加权我在第一个版本里天真地把UserCF、ItemCF、内容推荐的得分做了加权求和结果出来的推荐列表一团糟——用户明明在搜杭州的酒店结果推荐列表里混进了上海和北京的酒店。问题出在三路召回结果的候选池范围完全不同有的按省市展开有的只在同城展开直接加权等于抹掉了场景约束。正确做法是分层融合先按业务约束过滤同城、可预订状态、价格在用户接受区间再做候选人合并最后才做打分排序。而且推荐理由的生成也要跟着策略走——基于内容的推荐可以说因为这家酒店在您常住的商圈且星级匹配基于协同过滤的可以说和您偏好相似的人也选择了这家。我在最终版本里就是这么处理的def hybrid_recommend(user_id, city_filter, top_n10): candidates {} # 候选合并阶段各路结果写进入候选池 for hotel_id, score in recall_from_user_cf(user_id).items(): candidates[hotel_id] candidates.get(hotel_id, 0) 0.4 * score for hotel_id, score in recall_from_item_cf(user_id).items(): candidates[hotel_id] candidates.get(hotel_id, 0) 0.3 * score for hotel_id, score in recall_from_content(user_id).items(): candidates[hotel_id] candidates.get(hotel_id, 0) 0.3 * score # 业务约束过滤 city_candidates { hid: s for hid, s in candidates.items() if hotels.loc[hid, city] city_filter } return sorted(city_candidates.items(), keylambda x: x[1], reverseTrue)[:top_n]融合权重不是拍脑袋定的我的调参方法很简单拿一批人工标注的测试样本分别试(0.4, 0.3, 0.3)、(0.5, 0.3, 0.2)、(0.3, 0.4, 0.3)三组权重看哪个组合在离线评测里召回率和多样性指标同时最优。每组权重跑一遍评估脚本只要几十秒这个成本必须花。3. Python实现里最容易被忽略的四个工程细节算法模型在Jupyter Notebook里跑得好好的一部署成服务就崩这个问题我见过很多次。酒店推荐系统虽然业务不复杂但工程化落地的细节往往比算法本身更决定成败。3.1 稀疏矩阵的内存膨胀一个很容易踩爆的坑假设500个用户、2000家酒店评分矩阵全量存成DataFrame是500×2000100万个浮点数大概8MB看着不大对吧但当你把用户量放大到5万、酒店数量到20万的时候矩阵就是50万×20万用稠密矩阵存要几百GB完全不现实。解决办法有两个方向我自己的项目里用scipy的稀疏矩阵做了第一版from scipy.sparse import csr_matrix # 使用CSR格式存储稀疏用户行为矩阵 sparse_matrix csr_matrix(rating_pivot.values)但更推荐的做法是不要显式构建全量矩阵而是直接用(user_id, hotel_id, rating)的三元组数据一边扫描一边统计共现关系。UserCF的相似度其实只需要统计用户对之间的共同评分项用字典缓存效率极高。3.2 相似度矩阵的更新策略全量重算还是增量更新酒店推荐场景的特征是酒店池相对稳定用户行为持续新增。如果你每次来一个新行为就全量重算用户相似度矩阵那系统百分之百要跪。更实用的思路是离线定时重算比如每天凌晨2点基于前一日全量数据重算UserCF和ItemCF的相似度矩阵线上只做读取。这样相似度矩阵虽然有一天的延迟但对酒店这种低频消费场景完全够用换来的收益是线上接口的响应时间稳定在几十毫秒级别。如果你要处理的是当天新增行为可以用一个简单的方法只对有新增行为的用户增量更新其相似度行向量其他用户不动。但增量逻辑一旦复杂起来bug率会显著上升建议第一版不要做增量老老实实定时全量重算。3.3 推荐接口设计别把算法逻辑直接暴露给前端我见到很多初学者的Demo是这么写的Flask路由里直接调用recommend(user_id)返回一个酒店ID列表。看起来没毛病但真正的问题是前端需要的不只是ID还需要酒店名称、价格、评分、图片、推荐理由。所以接口设计一定要分两层内部层算法模块负责产出候选酒店ID和得分保证可测试性对外层Web接口负责组装返回结构补充酒店详情控制返回字段。我在项目里用的是FastAPI实现推荐服务接口顺手把数据组装也做了from fastapi import FastAPI app FastAPI() app.get(/api/recommend) def recommend_api(user_id: int, city: str, top_n: int 10): ranked hybrid_recommend(user_id, city_filtercity, top_ntop_n) result [] for hotel_id, score in ranked: info hotels.loc[hotels[hotel_id] hotel_id].iloc[0] result.append({ hotel_id: int(hotel_id), hotel_name: info[name], price: info[price], score: info[score], biz_district: info[biz_district], reason: generate_reason(user_id, hotel_id) }) return {user_id: user_id, items: result}接口层面还有两个细节值得注意第一要做超时兜底——推荐服务挂了不能影响主流程用try-except包一层失败时返回热门酒店第二要加缓存——Hot排行榜和热门城市的推荐结果一天内基本不会变用Redis或进程内缓存都行别让同一套计算反复跑。3.4 推荐结果的解释能力酒店场景比电商更需要酒店推荐和短视频推荐最大的不同在于用户不会随便看看就划走他需要一个下单的理由。我在系统里给每个推荐结果配置了一条推荐理由的生成逻辑基于命中的策略动态生成命中UserCF与您偏好相似的用户也选择了这家酒店命中ItemCF您曾经关注过同类型/同商圈的酒店命中Content酒店风格与您预订过的酒店高度匹配命中热榜本商圈人气酒店这个功能看着不起眼但对于酒店推荐系统的信任度提升帮助非常大。后续如果加排序模型理由字段还可以从attention权重里提取那是后话。4. 效果评估这个推荐到底行不行不能靠感觉说推荐系统的评估是一个系统性工程必须分维度去看。初学者最容易犯的错是只看准确率——推荐了10家酒店用户点了几家就算完了。这在信息流推荐里也就算了在酒店这种个性化要求高的低频场景里准确率根本反映不了问题。4.1 离线评测指标组合拳准确率、召回率、覆盖率、多样性、新颖度我把推荐系统的离线评估指标归成五类每个指标回答不同问题指标解决的问题计算公式简化准确率K推给用户的列表里有多少是用户实际喜欢的命中数 / K召回率K用户真实喜欢的物品里被推荐出来的比例命中数 / 用户行为总数覆盖率推荐系统能覆盖多少酒店被推荐过的酒店数 / 酒店总数多样性ILS列表内部是否同质化严重推荐列表中酒店间的平均相似度新颖度推荐的是不是全是热门酒店推荐列表中酒店的热度惩罚平均值具体实现的时候把用户行为按时间排序前80%当训练集后20%当测试集。对每个测试用户用训练好的模型产生top10推荐列表然后算指标。这样做的好处是可以统一复现不需要人工看结果。4.2 推荐多样性为什么在酒店场景里是核心指标举一个真实的情况。我的模拟数据里有一家高档型酒店在某个商圈是绝对热门UserCF和ItemCF都会拼了命把它往推荐列表里塞。结果就是20个不同用户拿到的推荐列表高度重合看起来好像推荐得很准但业务上非常糟糕——因为用户走进平台看到的推荐如果和直接看热榜没区别那个性化推荐的价值就归零了。为了让列表多样性提上去我做了一个不算复杂但很有效的处理在生成topN推荐列表时按酒店所属品牌或商圈做最大分散度约束。简单说就是同一品牌在推荐列表里最多出现1条同一商圈最多出现2条。这个规则在排序完成后执行一遍重排列表的观感立刻不一样。4.3 评估代码怎么组织才能反复用我把评估函数封装成独立模块输入是推荐结果和测试集标签输出是一组指标字典。这样调整算法参数、换权重、换数据切分方式后都可以一键重跑对比def evaluate(recs, test_set, hotel_pool, hotel_sim, top_k10): metric { precision: 0.0, recall: 0.0, coverage: 0.0, diversity: 0.0, novelty: 0.0 } num_users len(recs) hit_count 0 recovered 0 total_pos 0 recommended_set set() for uid, rec_list in recs.items(): seen set(test_set.get(uid, [])) hits [hid for hid in rec_list if hid in seen] hit_count len(hits) recovered len(set(rec_list[:top_k]) seen) total_pos len(seen) recommended_set.update(rec_list[:top_k]) # 多样性列表内物品两两相似度取平均后做减法 sim_sum 0 pair_count 0 for i in range(len(rec_list[:top_k])): for j in range(i 1, len(rec_list[:top_k])): sim_sum hotel_sim[rec_list[i]][rec_list[j]] pair_count 1 metric[diversity] 1 - (sim_sum / max(pair_count, 1)) metric[precision] hit_count / (num_users * top_k) metric[recall] recovered / max(total_pos, 1) metric[coverage] len(recommended_set) / len(hotel_pool) metric[diversity] / num_users # 新颖度可以用平均热门度的负向表达简化略 return metric我跑完第一版实验后的数据感受是这样的纯UserCF的召回率最高但覆盖率和多样性都垫底加上内容推荐后覆盖率能提升近20个百分点多样性也有明显改善纯规则热榜的覆盖率极高但准确率惨不忍睹。这个结果正好说明酒店推荐没有银弹融合是绕不开的路线。5. 实测中的意外情况与排查链路做完评估不代表系统就稳了真正的问题永远在跑通之后浮出来。这里记录几个我在实测中真实遇到的坑和排查过程供参考。5.1 诡异的你喜欢的酒店推荐了自己——候选过滤缺失第一次跑完推荐结果我打印给某用户生成的top10列表发现列表里出现了该用户已经预订过的酒店。检查后发现代码里确实写了过滤逻辑但过滤条件用的是user_rated[user_rated 0]的索引而实际评分矩阵里该用户的评分值并非严格大于0部分隐式行为被记录成了其他值。排查后我把过滤逻辑统一改为rated_set set(rating_pivot.columns[rating_pivot.loc[user_id] ! 0]) candidates [hid for hid, s in candidates.items() if hid not in rated_set]这个坑提示了一个重要原则永远不要在推荐候选过滤里写多种不一致的判断条件统一用一种方式取已交互集合然后用同一份set去做排除。5.2 相似度矩阵计算耗时随用户量暴涨用sklearn的cosine_similarity在5万用户规模上计算UserCF矩阵我实测耗时接近十分钟内存占用也飙升到2GB以上。这个成本虽然不是不能接受但如果每天全量重算一次还是有点肉疼。优化方案有两个建议先做第一个降采样只保留至少10条行为记录的用户参与相似度计算冷启动用户交给规则和内容推荐处理批量矩阵运算把用户分批比如每批5000人计算相似度写回时再聚合实际处理下来降采样的效果比想象中好——对活跃用户影响不大但用户量直接从5万降到1.8万计算耗时长降到原来的五分之一。5.3 热门酒店过度曝光导致的新颖度偏低推荐列表里反复出现热门酒店冷门但其实很适合用户的长尾酒店几乎没有曝光——这是所有协同过滤系统的通病。我的应对思路分两层召回阶段不刻意打压热门保证覆盖排序阶段引入新颖度惩罚把热门酒店的最终得分乘以一个衰减系数衰减幅度取决于该酒店的热门度排名。def novelty_penalty(hotel_id, rank_hotness, gamma0.2): # rank_hotness: 0~1越接近1表示越热门 return 1 - gamma * rank_hotness参数gamma不宜太大否则推荐列表全是冷门酒店点击率反而下降。我试下来0.2左右是个比较安全的取值既能露出一些长尾酒店又不至于影响整体精准度。5.4 环境部署时被Python版本和依赖坑惨的记录这个项目我一开始基于Python 3.8开发后面在另一台机器上部署时发现环境是3.10结果scikit-learn版本不兼容pivot_table和某些API的返回类型在Pandas 2.0版本里有变化代码直接跑挂。后来老老实实用了虚拟环境锁版本。强烈建议所有读者在项目根目录放一个requirements.txt并且注明Python版本号尤其是要用到fastapi、sklearn、pandas、numpy这些依赖的项目版本锁定能省掉几小时的排查时间。6. 从Demo到可上线之间还差这几步以上把推荐算法和评估讲清楚了但Demo和能上线用的系统之间还是有明显距离。基于我做这套系统的经验最后几点如果有余力务必补上。第一个是行为日志埋点。推荐系统的生命线是行为数据循环用户看了推荐、点了哪家、最终订了哪家这些数据一定要回流到行为表里推荐效果才能持续迭代。没有埋点回流推荐系统的效果就是开环的永远只能靠离线模拟。第二个是推荐后台的可视化配置。权重参数、热门惩罚系数、过滤规则如果每次都改代码效率非常低。我后来抽空写了一个简单管理页面用SQLite存参数、FastAPI提供配置接口前端调参数后立即生效。这个改造投入成本不大但对日常运营调优帮助很大。第三个是监控报警。线上推荐接口的响应时间、推荐结果为空的比例、热门榜单的返回都值得做一套极简监控。哪怕只是写一个定时脚本每天打一遍推荐接口确认返回非空也比彻底没监控强。做酒店推荐系统难点其实不在于算法本身多么高深而在于把数据—算法—工程—评估这条链路完整打通。很多人在算法上花大把时间调参最后挂在数据埋点和工程落地这个顺序一定不要搞反。你现在手里的版本只要跑通了召回、排序、评估、接口这四段闭环就已经具备了后续扩展的基础。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进