
旅游推荐系统这个方向我在实战营里带过不少学员也帮企业做过类似的内部项目。说实话网上打着“基于Python旅游推荐系统”旗号的教程很多但大多数要么是套了一个Django壳子的假推荐要么是只放了一个协同过滤的Jupyter Notebook根本跑不起来。我今天这篇是想把我自己从爬虫采集、数据清洗、协同过滤算法实现到Django框架整合、可视化展示这条完整链路走一遍把我踩过的坑、觉得关键的设计决策以及真正能落地的代码逻辑都摊开讲。这篇文章适合这几类人正在做毕业设计或课程设计、需要一套能演示的完整系统的同学想系统了解协同过滤算法到底怎么从0写到1的开发者以及想搞明白爬虫数据如何和Web应用串起来的产品经理或全栈爱好者。核心关键词就七个Python、爬虫、机器学习、协同过滤算法、Django框架、可视化、旅游推荐。你看完至少能学会两件事第一协同过滤不是玄学用Python手写也就一百多行代码第二如何把离线算法包装成在线可用的推荐服务。1. 旅游推荐系统到底在解决什么问题从业务痛点倒推技术选型1.1 需求侧的真实痛点先别急着写代码我们先想想这个系统到底要干什么。机票酒店的价格越来越透明但“去哪儿玩”反而是更大的决策负担。打开任何一家OTA平台热门城市动辄几千条景点评论和攻略用户筛选成本极高。一个旅游推荐系统核心不是“把评分最高的景点推给用户”而是“根据用户的历史偏好推测出他可能感兴趣的冷门景点”。这个差异化需求很明确如果一个用户经常搜索古镇、寺庙、文化遗址那你给他推迪士尼就不合适哪怕迪士尼评分再高。这就是推荐系统和搜索系统的本质区别——搜索是用户带着明确意图来找内容推荐是系统根据隐式信号主动给内容。1.2 为什么选协同过滤而不是深度学习很多人一看到“机器学习”四个字就条件反射地想上BERT、上Graph Neural Network。我可以负责任地说在这个项目场景下那就是杀鸡用牛刀。原因有三第一数据量根本不够。深度学习模型动辄需要百万级样本才能稳定收敛而一个课程设计或中小型旅游系统的用户行为数据撑死几万条。在这个量级下协同过滤的推荐质量完全不输复杂模型。第二可解释性要求高。用户看到系统给他推了“南浔古镇”系统得能解释“因为你喜欢乌镇”才算有说服力。协同过滤天然是基于“相似用户”或“相似物品”做推荐解释逻辑很直白。而深度学习模型的可解释性到今天仍然是个大问题。第三开发效率与资源成本。协同过滤算法用纯Python Pandas就能实现训练一次秒级完成。而深度学习需要GPU训练、模型调参、推理服务优化一整套工程链路对一个单体Django项目来说太沉重了。1.3 技术栈选型模块技术选型理由后端Web框架Django Django REST Framework自带Admin后台和ORM开发速度快适合快速交付数据采集requests BeautifulSoup4轻量、调试方便适合中小规模爬虫数据存储MySQL SQLAlchemyDjango ORM原生支持事务和索引成熟推荐算法基于物品的协同过滤ItemCF 基于用户的协同过滤UserCF可解释、易实现、效果稳定数据分析Pandas NumPy处理评分矩阵的标配可视化ECharts 原生HTML模板交互丰富、上手简单Django模板直接渲染缓存Redis可选缓存相似度矩阵和TopN结果提升在线响应速度这套组合是典型的“小学霸”配置每一样都不算最前沿但组合起来非常顺手。尤其是Django自带的Admin后台配合爬虫采集回来的景点数据一个下午就能做出一套可维护的数据管理界面。2. 数据源头爬虫采集景点信息与用户行为数据的完整设计2.1 爬虫目标与字段设计推荐系统最怕的就是没数据。先定数据源我建议抓取两类数据景点基础信息名称、所在城市、景点标签、评分、评论数量、游玩推荐时长、景点详情页URL、经纬度。用户行为数据这个爬虫抓不到但有两条路——一条是使用公开数据集如携程/马蜂窝的脱敏评论数据另一条是给自己系统做埋点从注册用户的行为日志里攒。学员项目我一般建议后者因为能证明你做了“数据采集数据存储数据应用”的完整闭环。字段设计是所有后续工作的地基。一个常见的坑是只抓“景点名称评分”等做到协同过滤时才发现缺少“用户ID”维度整个评分矩阵根本构建不出来。所以设计表结构时就要想清楚一个用户可以对多个景点评分一个景点可以属于多个标签这是典型的多对多关系。2.2 爬虫代码核心实现我用 requests BeautifulSoup 写一个轻量爬虫作为示例。假设目标源是某个公开旅游网站的热门景点列表页我们拿到的是HTML结构。这里我以兜底的真实场景为例解析列表页中的景点卡片提取字段。import requests from bs4 import BeautifulSoup def fetch_spot_list(page_url): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 } resp requests.get(page_url, headersheaders, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) spots [] # 假设每个景点卡片是 li.scenic-item for item in soup.select(li.scenic-item): name_tag item.select_one(.name a) if not name_tag: continue name name_tag.get_text(stripTrue) detail_url name_tag.get(href) score_tag item.select_one(.score) comment_tag item.select_one(.comment-count) tag_tags item.select_one(.tags) spots.append({ name: name, url: detail_url, score: float(score_tag.get_text(stripTrue)) if score_tag else 0.0, comment_count: int(comment_tag.get_text(stripTrue)) if comment_tag else 0, tags: [t.get_text(stripTrue) for t in tag_tags.select(a)] if tag_tags else [] }) return spots2.3 反爬应对策略写爬虫最忌讳的是“裸奔”。站点常见的反爬手段有三种IP限频、User-Agent校验、请求头校验。我实际项目里的处理方案请求头伪装。把完整的User-Agent、Referer、Accept-Language都带上模拟真实浏览器。注意Referer一定要有很多站点校验的就是它。随机延时。每次请求后time.sleep(random.uniform(1, 3))不要让请求频率看起来像机器。重试机制。用requests.adapters.HTTPAdapter(max_retries3)做容错遇到网络抖动自动重试。你要真遇到了需要登录才能看到的数据那就得上Cookie池或模拟登录但这门课的内容就太多了。中小项目里只要做好限频和伪装99%的静态列表页都能拿下来。2.4 数据清洗与存储SQLAlchemy写入MySQL爬下来是脏数据必须清洗。我常用的清洗规则有字段去空comment_count缺失直接填0score缺失填平均值。去重同一个景点可能在不同列表页中出现用name city做唯一键。标签标准化把“历史古迹/人文古迹/文化古迹”这类相近词统一成“历史古迹”。存储我用SQLAlchemy来写ORM模型。Django项目其实可以直接用Django ORM但爬虫脚本通常是独立运行的用SQLAlchemy让爬虫代码不依赖Django环境解耦更干净。from sqlalchemy import create_engine, Column, Integer, String, Float from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker Base declarative_base() class ScenicSpot(Base): __tablename__ scenic_spots id Column(Integer, primary_keyTrue, autoincrementTrue) name Column(String(100), nullableFalse) city Column(String(50), indexTrue) score Column(Float, default0.0) comment_count Column(Integer, default0) tags Column(String(255)) detail_url Column(String(500)) def save_spots(spot_list, db_urlmysqlpymysql://root:123456localhost/travel_db): engine create_engine(db_url, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session() for spot in spot_list: exists session.query(ScenicSpot).filter_by(namespot[name]).first() if exists: continue session.add(ScenicSpot(**spot)) session.commit() session.close()这里有个小坑SQLAlchemy的create_all只负责建表不负责改表。如果你中途给模型加了字段它是不会自动ALTER TABLE的。开发阶段直接用Base.metadata.drop_all()重建虽然简单但生产环境必须用Alembic迁移。3. 协同过滤推荐的核心实现相似度计算与TopN推荐3.1 基于用户的协同过滤UserCF找和你口味相似的人协同过滤有一条核心假设喜欢同一批景点的人口味是相似的。UserCF术语叫“人以群分”它的推荐逻辑分三步计算用户两两之间的相似度。相似度通常用余弦相似度或皮尔逊相关系数。对目标用户找出TopK个最相似的用户。把这TopK个用户喜欢的、但目标用户没接触过的物品按加权分数排序输出TopN。我用一个例子来直观解释。假设有三个用户用户A喜欢的景点故宫、颐和园、八达岭长城。用户B喜欢的景点故宫、颐和园、周庄。用户C喜欢的景点西湖、灵隐寺、宋城。A和B都喜欢故宫和颐和园所以A和B的相似度很高。当A没有去过周庄时系统就会推荐周庄给A。这个逻辑非常直观。3.2 基于物品的协同过滤ItemCF物以类聚ItemCF的逻辑和UserCF正好反过来如果很多喜欢故宫的用户也喜欢颐和园那么故宫和颐和园的相似度就高。当用户喜欢故宫时系统就会推荐颐和园给他。两个算法用起来哪个好实战经验是UserCF适合社交属性强的场景推荐结果带有“你朋友喜欢”的意味ItemCF更适合电商和旅游这种物品数量相对稳定、用户兴趣相对分散的场景。因为用户偏好会随时间变化但物品的相似关系相对稳定ItemCF计算的相似度矩阵可以缓存很久。所以我的主推算法是ItemCF。3.3 ItemCF的Python手写实现先看核心的数据准备部分。我们需要构建一个“用户-物品”评分矩阵矩阵的行是用户ID列是景点ID值可以是显式评分1~5分也可以是隐式反馈浏览过记1没浏览记0。 隐式反馈也是协同过滤中很常见的输入形式。import numpy as np import pandas as pd from collections import defaultdict def build_user_item_matrix(ratings_df): # 输入包含 uid, sid, rating 三列的DataFrame # 输出用户-物品矩阵、物品-用户倒排表 matrix ratings_df.pivot_table(indexuid, columnssid, valuesrating).fillna(0) return matrix def compute_item_similarity(matrix): 计算物品之间的余弦相似度 # 矩阵转置变成 物品 x 用户 item_user matrix.T.values.astype(float) # 余弦相似度: cos A·B / (|A|*|B|) norm np.sqrt(np.sum(item_user ** 2, axis1, keepdimsTrue)) normalized item_user / np.where(norm 0, 1, norm) sim_matrix np.dot(normalized, normalized.T) return sim_matrix这里用余弦相似度是因为余弦值只关心向量方向不关心向量长度。如果一个用户给所有景点都打了5分另一个用户给所有景点都打了3分他们的余弦相似度依然会很高这对推荐来说反而是好事——我们在找的是偏好模式而不是绝对分数。评估一个物品和你的某个已评价物品的相似度后如何预测你可能给某个新物品打多少分标准做法是加权求和def top_n_recommend(user_id, matrix_df, sim_matrix, n10): # 用户对每个物品的已有评分 if user_id not in matrix_df.index: return [] user_ratings matrix_df.loc[user_id].values item_ids matrix_df.columns.tolist() # 初始化得分累加器和分母累加器 scores defaultdict(float) norm defaultdict(float) for i in range(len(item_ids)): if user_ratings[i] 0: continue # 跳过用户没有交互过的物品 for j in range(len(item_ids)): if i j or user_ratings[j] 0: continue # 跳过物品自身和用户已经评分过的物品 sim sim_matrix[i][j] scores[item_ids[j]] sim * user_ratings[i] norm[item_ids[j]] abs(sim) if not scores: return [] # 归一化并排序 ranked [(item_id, scores[item_id] / norm[item_id]) for item_id in scores] ranked.sort(keylambda x: x[1], reverseTrue) return ranked[:n]这段代码就是整个推荐引擎的心脏。逻辑上把相似物品按相似度和你的已有评分加权求和最后再做一次归一化防止高分物品被热门物品碾压。需要注意的坑norm[item_id]可能为0。当所有相似度都是0时罕见但存在分母为0会抛错。上面代码里加abs(sim)的分母累加就能规避掉大部分为0的情况。3.4 推荐效果评估准确率、召回率与离线实验写完了推荐引擎还不够你还得向老师或面试官证明这套算法是有效的。离线评估最常用的是留一法把所有历史行为数据随机分成训练集和测试集在训练集上给用户生成TopN推荐列表然后看测试集里的真实行为有多少被推荐到了。def evaluate_precision_recall(predicted, actual): if len(actual) 0: return 0.0, 0.0 hit len(set(predicted) set(actual)) precision hit / len(predicted) if predicted else 0.0 recall hit / len(actual) return precision, recall光看准确率不够还得看覆盖率。覆盖率衡量推荐系统是否能覆盖到长尾物品如果系统永远只推荐热门Top10准确率再高也没有实际意义。覆盖率计算公式覆盖率 推荐结果中包含的物品数 / 物品总数我在实际项目中一个很有价值的经验是ItemCF在长尾覆盖率上天然优于UserCF。因为热门物品之间的相似度很高物品相似度矩阵会形成“热门互推”的圈子效应。为了解决这个问题可以在相似度计算时对热门物品做惩罚即引入1 / log(1 物品流行度)作为权重因子。4. Django框架集成推荐引擎如何与Web应用对接4.1 Django项目结构设计写好了算法下面是你真正的“工程化”关卡。Django项目结构设计我会这样组织travel_recommend/ ├── manage.py ├── travel/ # 主配置应用 │ ├── settings.py │ └── urls.py ├── apps/ │ ├── spots/ # 景点管理 │ │ ├── models.py │ │ ├── views.py │ │ └── urls.py │ ├── users/ # 用户与认证 │ ├── ratings/ # 用户评分与行为 │ └── recommend/ # 推荐引擎 │ ├── algorithm/ # 协同过滤算法代码 │ ├── services.py # 推荐服务封装 │ └── views.py # API视图 ├── static/ # 静态文件ECharts等 └── templates/ # Html模板关注一个核心点算法模块和Django Web层分离。algorithm/目录下的代码不依赖Django的任何东西只接收Pandas DataFrame和参数输出推荐结果。这样你就可以在一个独立的脚本里离线训练、评估、调优全部确认OK之后再做服务接入。4.2 推荐服务封装Django的views.py不应该直接处理算法细节要做一个service层做封装。我的做法是# apps/recommend/services.py import pandas as pd from .algorithm.itemcf import compute_item_similarity, top_n_recommend class RecommendService: def __init__(self): self.sim_matrix None self.matrix_df None self.item_ids [] self._load_from_db() def _load_from_db(self): # 从数据库读取评分数据 ratings list(Rating.objects.all().values(uid, sid, rating)) df pd.DataFrame(ratings) self.matrix_df df.pivot_table(indexuid, columnssid, valuesrating).fillna(0) self.sim_matrix compute_item_similarity(self.matrix_df) def recommend_for_user(self, uid, topn10): if uid not in self.matrix_df.index: return self._fallback_hot_spots() result top_n_recommend(uid, self.matrix_df, self.sim_matrix, topn) return [{sid: sid, score: round(float(score), 4)} for sid, score in result] def _fallback_hot_spots(self): # 冷启动兜底按评论数取热门景点 return list(ScenicSpot.objects.order_by(-comment_count).values(id, name)[:10])4.3 接口设计Django视图层我用DRF来编写API。一个很常见的接口约定是GET /api/recommend/?uid1topn10返回{ uid: 1, recommendations: [ {sid: 101, name: 周庄, score: 4.95}, {sid: 102, name: 同里古镇, score: 4.81} ] }前端页面用Ajax调用这个接口动态渲染推荐结果。为什么不用Django模板直接渲染因为推荐结果往往需要异步刷新比如用户切换城市、点击“换一批”模板渲染会刷新整个页面体验很生硬。4.4 加载性能优化协同过滤最耗时的操作是两两计算物品相似度。假设有1000个景点相似度矩阵是1000x1000也就是100万个浮点数内存占用约8MB其实不大。但每次请求都重新计算一次是完全不可接受的。解决办法两个服务启动时预热加载一次放到内存缓存中。上文RecommendService.__init__里的逻辑就是干这个的。借助Redis缓存序列化后的相似度矩阵。Django服务重启后不需要重新计算直接从Redis里拉。import redis r redis.Redis(hostlocalhost, port6379, db0) def get_sim_matrix(): key sim_matrix cached r.get(key) if cached: return pickle.loads(cached) sim compute_item_similarity(...) r.set(key, pickle.dumps(sim), ex86400) return sim5. 可视化从数据图表到用户画像展示5.1 为什么要做可视化既然是完整项目可视化就是脸面。很多人以为可视化就是画几个统计图撑场面其实不对。在推荐系统项目里可视化的真正价值在于让看演示的人能一眼看懂“推荐为什么合理”。我做三张图效果非常好景点热度Top10柱状图直观看出数据分布和热门景点。用户偏好标签词云展示当前用户的偏好画像。推荐结果评分分布雷达图展示推荐结果在不同维度的特征。5.2 ECharts集成到DjangoECharts通过CDN引入或下载到static/js/目录。在Django模板中写一个折线图和柱状图的仪表盘div idhotelRank styleheight: 400px;/div script src{% static js/echarts.min.js %}/script script var chart echarts.init(document.getElementById(hotelRank)); fetch(/api/stats/hot-spots/) .then(res res.json()) .then(data { chart.setOption({ title: { text: 景点热度排名按评论数 }, tooltip: {}, xAxis: { type: category, data: data.names }, yAxis: { type: value }, series: [{ type: bar, data: data.comment_counts, itemStyle: { color: #4A90E2 } }] }); }); /script后端对应的统计接口也很简单用Django ORM直接分组聚合。def hot_spots_stats(request): results ScenicSpot.objects.order_by(-comment_count)[:10] return JsonResponse({ names: [s.name for s in results], comment_counts: [s.comment_count for s in results] })5.3 用户画像的可视化表达用户画像是推荐系统实现个性化的源头。我在用户详情页做了一张雷达图展示用户在不同景点品类自然风光、历史古迹、主题乐园、城市观光、美食探店上的偏好权重。这个偏好权重从哪来从用户的评分记录统计而来。用户在某个品类下评分数量多、评分高这个品类的偏好权重就高def build_user_profile(uid): ratings Rating.objects.filter(uiduid).select_related(spot) profile defaultdict(float) for r in ratings: for tag in r.spot.tags.split(,): profile[tag] r.rating # 归一化 total sum(profile.values()) return {k: round(v / total, 3) for k, v in profile.items()}把profile传给前端ECharts雷达图即可。要美观的话可以在标签选择上下功夫——不用全部标签只取前5个主品类。6. 从开发到部署踩坑记录与性能优化建议6.1 冷启动问题新用户和新景点怎么办冷启动是推荐系统经典难解决但又绕不开的问题。新用户没有任何行为数据协同过滤算法完全失效。我的处理方案是分层的新用户无行为数据直接给热门景点Top10。这是最保守也最不容易出错的策略。新用户首次交互后立即用ItemCF基于他刚浏览/收藏的1~2个景点做相关推荐。这比等用户攒够了5条评分再推荐体验好得多。新景点无评分数据依靠爬虫抓到的标签信息做基于内容的匹配。景点标签和用户偏好标签做相似度计算比如用户偏好在“历史古迹”就推荐尚未有评分但标签含“历史古迹”的新景点。在我的代码实现里_fallback_hot_spots就是第一层兜底策略。第二层策略要在ratings表的模型里加一个信号处理每当这条用户行为写库时同步触发一次推荐缓存刷新。6.2 数据稀疏性与评分矩阵填充如果1000个景点只有500个用户打分平均每个用户只看了10个景点那评分矩阵的稀疏度是99%。稀疏矩阵下物品相似度计算会非常不稳定——两个景点可能只被同一个用户评价过就被计算成高相似度。缓解方案有两个使用“同现矩阵”替代原始评分矩阵。即先统计两两景点被多少个共同用户评价再除以各自的热度归一化。这是Slope One算法的思想实现简单效果在稀疏场景下更好。对相似度设置置信度阈值只有共同评分用户数3时相似度才有效。否则直接置0。def compute_item_similarity_with_conf(matrix_df, min_support3): # 矩阵转置: item x user item_user matrix_df.T # 共现矩阵 co_occurrence item_user.dot(item_user.T) co_occurrence co_occurrence.to_numpy() # 只保留共现次数超过阈值的 co_occurrence[co_occurrence min_support] 0 # 计算余弦相似度 ...这一招在你实际数据集中会带来非常显著的推荐质量提升。哪怕评分很少至少不会出现“一共只有一个人同时看了故宫和颐和园就认为它们极相似”的荒唐结果。6.3 数据集规模变大时怎么办如果你的毕设系统要演示并发比如课堂上20个人同时访问Django默认的单进程开发服务器可能扛不住高并发。部署时我用如下组合组件作用Nginx静态文件服务 反向代理 负载均衡GunicornDjango应用服务器多worker运行Redis缓存相似度矩阵 推荐结果过期控制MySQL数据持久化一个比较实用的Gunicorn配置gunicorn travel.wsgi:application -w 4 -b 127.0.0.1:8000 --timeout 604个worker能同时处理4个请求对一个演示系统来说绰绰有余。再把相似度矩阵预热到Redis推荐接口的响应时间基本稳定在50ms以内。6.4 我实际踩过的三个坑第一个坑爬虫数据编码问题。很多旅游网站实际返回的是GBK编码直接用resp.text会乱码。正确做法是先看resp.encoding或响应头Content-Type里的charset再用resp.content.decode(gbk, errorsignore)强制解码。第二个坑Django的ORM查询N1问题。在做推荐结果详情展示时如果对每个推荐结果都查询一次景点信息会导致数据库查询量爆炸。解决方案是在循环外用select_related或prefetch_related预加载。recommended_ids [...] spots ScenicSpot.objects.filter(id__inrecommended_ids).prefetch_related(tags) id2spot {s.id: s for s in spots}第三个坑不能忽略时间衰减。如果用户上个月喜欢古镇这个月可能已经变了。给评分加上时间衰减权重后效果更贴近真实比如decay_weight 1 / (1 0.1 * days_since_rating)这个公式虽然没有论文里的指数衰减那么学术但工程上非常好用代码也只有一行。最后再说点实在的这套系统我前前后后重新搭过三遍第一遍纯粹为了跑通第二遍才发现算法评估的重要性第三遍才真正理解工程落地时那些维度——冷启动、稀疏性、并发、缓存——比算法本身更考验人。如果你是自己练手不要贪多先把ItemCF写好把Django接口跑通再慢慢补可视化、补部署。如果你是在做课程设计记住答辩老师最看重的往往不是你用了多复杂的模型而是你能不能把数据从哪里来、推荐结果怎么算出来的、系统怎么上线这条链路讲清楚。代码里我习惯加一个utils.py把所有离线的脚本——爬虫采集、数据集预处理、模型评估——全都放进去写清楚调用顺序。这样后续任何人拿到项目只需要按顺序执行就能完整复现从原始数据到推荐结果的全过程。这也是我给学生交付源码时的标准做法运行步骤越清晰这套系统的可复盘价值就越高。