
简介这是面向计算机及相关专业毕业设计、课程设计场景的一份旅游推荐系统论文文档完整覆盖从选题背景、技术选型、系统架构设计、功能模块划分、数据库设计到系统实现与测试的全过程。文档基于Python语言选用Django框架和MySQL数据库详细阐述了协同过滤等推荐算法在旅游场景中的落地方式并介绍了用户管理、旅游信息展示、推荐算法、用户反馈等核心模块的设计思路同时附有中文摘要、英文摘要、目录及各章节正文便于学生撰写论文或搭建同类系统时参考。资源为单个DOCX文档压缩包大小约1.6MB排版结构清晰可直接按章节查阅复用。已有332人浏览学习对了解旅游推荐系统的开发流程和论文写作规范具有实用价值。1. 旅游推荐系统真正难的不是算法而是数据做毕设或者接外包项目时基于python的旅游推荐系统的设计与实现这类题目看着常规实际翻车的比例相当高。很多人把精力全砸在协同过滤、矩阵分解这些模型上最后发现用户行为数据稀疏到模型根本学不出东西演示时只能靠写死的假数据硬撑。这个系统真正难的不是推荐这一层而是从零开始把用户兴趣和景点特征这两套数据盘活让冷启动场景下也有东西可推。我见过太多论文写得很漂亮、代码一跑就露馅的毕设也见过真上线跑在商家小程序里的同类系统。两者差距不在算法而在数据链路和工程细节。这篇文章会把旅游推荐系统从数据采集、特征加工、推荐引擎到Web接口的完整链路拆开讲每个环节都给可直接照抄的代码和参数经验。适合正在做毕设、想学期内交付完整项目、或者打算把推荐功能接进自己业务的人看。2. 先解决数据从哪来POI 采集与本地画像建设2.1 推荐系统没数据等于空转先承认拿不到真实行为日志旅游推荐系统和电商推荐系统最大的不同是用户行为数据天然稀疏。一个用户一年可能就出游一两次浏览、收藏、下单的日志量跟购物完全不是一个量级。如果你是自己搭一套项目做演示或跑通业务闭环最常见的数据获取路径有两条一是用公开的景点 POI 数据集二是自己写爬虫去抓景区信息。这里先泼一盆冷水爬虫抓到的数据只能当景点特征用用户的真实偏好你还是得靠规则模拟出来。具体来说我一般会先抓 POI 基础数据包括景点名称、所在城市、门票价格、开放时间、评分、评论数、标签例如亲子山水古城漂流等再对每个景点做一套出游属性标注例如适合游玩的季节、建议游玩时长、人均消费区间。这两层合在一起才是推荐引擎真正要用的物品侧画像。2.2 POI 数据采集用 Request 抓景区信息并落成干净的 CSV常见做法是抓取公开的景点列表页先拿到每个景点的详情页链接再逐个解析详情字段。下面是一个能跑的最小采集脚本目标是抓取景点名称、评分、标签和城市并写入 CSV。这里的核心有两点一是用requests.Session维持连接避免被频繁断开二是把解析逻辑和字段清洗分开方便后续加字段。import requests import csv import time from bs4 import BeautifulSoup def fetch_poi_list(city, page1): # 这里以公开页面结构为例实际需要按目标站点调整 url fhttps://example.com/scenic/search?city{city}page{page} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: fhttps://example.com/scenic } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) poi_list [] # 假设每个景点卡片在 .poi-item 节点内 for item in soup.select(.poi-item): name item.select_one(.poi-name).text.strip() score item.select_one(.poi-score).text.strip() tags [t.text.strip() for t in item.select(.poi-tag)[:5]] poi_list.append({ city: city, name: name, score: float(score) if score else 0.0, tags: |.join(tags), }) return poi_list def save_poi(data, filepathpoi_raw.csv): # 用追加模式写文件方便断点续爬 with open(filepath, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[city, name, score, tags]) writer.writerows(data) if __name__ __main__: for city in [北京, 成都, 西安]: for page in range(1, 6): try: result fetch_poi_list(city, page) save_poi(result) time.sleep(1.5) # 控制请求频率别把对方站点打挂 except Exception as e: print(f[error] {city} page {page}: {e}) continue这段代码的值不在于爬得多花哨而在于三点用utf-8-sig编码写 CSV 可以避免 Excel 打开中文乱码用time.sleep(1.5)做频率控制这是爬虫不翻车的基本素养把tags用|连接而不是直接存列表后面加载到 DataFrame 时只要split(|)就能还原。抓完后先别急着跑推荐看一眼数据量。如果总量不到 200 个有效景点这个基础是撑不起推荐效果的。2.3 把 POI 变成可计算的特征向量化、归一化和实体对齐原始字段不能直接进推荐引擎。字符串标签需要向量化数值字段需要归一化更重要的是同一个景点在不同数据源的 ID 不同这个对齐问题。中文场景里西安的大雁塔和大慈恩寺在大众点评、携程、去哪儿上可能是三个不同条目等你把多个来源的数据合并时就会出现重复的景点实体。先解决文本标签向量化使用 scikit-learn 的TfidfVectorizer把标签和景点简介拼成文档得到每个景点的内容特征向量。这里有个容易踩坑的参数组合——min_df和max_df。min_df设太小低频标签会引入噪声比如某个景点出现过一次的禅修min_df设太大又会让亲子山水这种核心标签被过滤掉。我一般对短文本标签设置min_df1, max_df0.7当样本量超过 500 个景点时再把min_df调到 2。import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer # 读取上一步采集的 POI 数据 df pd.read_csv(poi_raw.csv) # 将标签和名称拼接成一个文档字符串 df[doc] df[name] df[tags].fillna() # 向量化min_df1 保留出现 1 次以上的标签max_df0.7 过滤 70% 以上文档都出现的词 vec TfidfVectorizer(min_df1, max_df0.7, token_patternr(?u)\S) tfidf_matrix vec.fit_transform(df[doc]) # 把稀疏矩阵转 DataFrame后续做相似度计算可以直接用 poi_feats pd.DataFrame( tfidf_matrix.toarray(), indexdf[name], columnsvec.get_feature_names_out() ) print(poi_feats.shape)token_pattern这一段很多人不管但在中文分词没有单独引入 jieba 的情况下默认的 token 规则会把中文长串当成一个 token基本等于向量化白做。所以你要么在doc里事先用空格把切分好的标签隔开上面代码就是这么处理的要么就要在TfidfVectorizer外再套一层 jieba 分词函数。标签向量化做完数值字段评分、门票价用MinMaxScaler压到 0-1 区间避免价格把文本相似度淹没。3. 推荐引擎的实现从协同过滤到混合推荐3.1 算法选型小数据量别碰矩阵分解先看协同过滤与基于内容按论文的路数推荐算法部分是核心。但如果用户行为数据只有几千条SVD 分解出来的隐因子基本没有可解释性评委一问为什么推荐这个景点你答不上来。我建议的落地顺序是先实现一个基于内容的推荐Content-Based用于冷启动和解释性推荐再实现一个基于用户的协同过滤UserCF用于有行为数据后的个性化召回最后把两者按权重混合。论文里设计与实现怎么体现深度不是算法多先进而是你在协同过滤里考虑了时间衰减在混合权重里做了置信度调节。这些细节比叠模型更能说明你真的把系统做到了能用的程度。3.2 基于内容的推荐用户偏好画像生成与 TopN 召回基于内容的推荐核心是给每个用户算出一个偏好向量然后跟景点特征向量做余弦相似度排序。用户没行为时怎么算偏好用显式反馈——用户在注册时勾选的兴趣标签或者对几个种子景点的打分。下面这个实现把用户偏好定义为种子景点的特征向量按评分加权平均。import numpy as np from sklearn.metrics.pairwise import cosine_similarity def build_user_profile(seed_scores, poi_feats, topn20): seed_scores: {景点名: 用户评分}评分范围 1-5 # 取用户打过分的景点特征向量 seed_names list(seed_scores.keys()) seed_vecs poi_feats.loc[seed_names].values # 评分作为权重加权平均合成用户偏好向量 weights np.array([seed_scores[n] for n in seed_names]).reshape(-1, 1) profile (seed_vecs * weights).sum(axis0) / weights.sum() # 计算用户对所有景点的相似度排除已经打分的 sims cosine_similarity([profile], poi_feats.values)[0] sim_series pd.Series(sims, indexpoi_feats.index) sim_series sim_series.drop(seed_names, errorsignore) return sim_series.sort_values(ascendingFalse).head(topn) # 示例用户给了三个景点评分 seed {大雁塔: 5, 陕西历史博物馆: 4, 大唐芙蓉园: 3} recommendations build_user_profile(seed, poi_feats) print(recommendations)加权平均的合理性在于用户打 5 分的景点特征比如标签里历史博物馆权重高会在 final profile 里被放大打 3 分的景点特征贡献弱一些。这里有个参数经验——topn第一次不要给太大给 10 个就够因为你后续还要做过滤规则例如过滤用户已经去过的城市、过滤超出预算的景点。另外profile的归一化去掉也没有关系因为余弦相似度本身不受向量模长影响但保留weights.sum()在这里只是为了解释权重比例的合理性。3.3 基于用户的协同过滤共现矩阵、相似度计算的 PySpark 与纯 Python 两种姿势UserCF 的整体思路是跟你品味相似的人去过的景点你大概率也想去。落地需要一张用户-景点评分矩阵行是用户列是景点值是行为分数。行为分数怎么定这里建议收藏 1 分、搜索点击 0.5 分、评论 2 分、预订 3 分没有真实数据时可以用脚本生成模拟行为日志但注意在论文里把模拟数据的生成方式写清楚别让评委以为这是真实生产数据。纯 Python 实现适合演示计算逻辑直白。但如果行为数据上万条纯for循环的相似度计算会慢到让你怀疑人生建议切到 pandas 向量化或者直接上 PySpark。下面给出一个适合毕设演示的 pandas 实现。import pandas as pd import numpy as np # 模拟行为日志user_id, poi_name, score logs pd.DataFrame({ user_id: [1, 1, 1, 2, 2, 3, 3, 3], poi_name: [大雁塔, 回民街, 兵马俑, 大雁塔, 华清宫, 回民街, 城墙, 大雁塔], score: [3, 1, 2, 3, 2, 1, 2, 3], }) # 构建用户-景点矩阵空值填 0 matrix logs.pivot_table(indexuser_id, columnspoi_name, valuesscore).fillna(0) # 计算用户之间的余弦相似度 def cosine_sim(a, b): a, b np.asarray(a, dtypefloat), np.asarray(b, dtypefloat) denom (np.linalg.norm(a) * np.linalg.norm(b)) or 1e-9 # 防止除 0 return np.dot(a, b) / denom users matrix.index.tolist() sim_matrix pd.DataFrame( np.array([[cosine_sim(matrix.loc[u], matrix.loc[v]) for v in users] for u in users]), indexusers, columnsusers ) def recommend_by_user_cf(user_id, top_k3): # 找到最相似的 2 个用户排除自己取他们评价过但当前用户没去过的景点 sims sim_matrix.loc[user_id].drop(user_id).sort_values(ascendingFalse) neighbors sims.head(2).index.tolist() rec_pois set() for n in neighbors: rec_pois.update(matrix.loc[n][matrix.loc[n] 0].index.tolist()) seen set(matrix.loc[user_id][matrix.loc[user_id] 0].index.tolist()) return list(rec_pois - seen)[:top_k] print(recommend_by_user_cf(1))相似度计算看似简单一个容易翻车的点是cosine_sim在两条全零向量两个用户都没有任何行为记录时会返回 NaN然后整行数据污染。代码里denom兜底解决这个问题。论文里如果写了 UserCF 是核心算法那么建议在相似度计算中引入皮尔逊相关系数而不是余弦相似度理由是可以中心化用户评分偏好——有的人习惯打高分有的人只打低分皮尔逊能把这种偏差消除。3.4 混合最终的排序时间衰减与流行度惩罚单独跑协同过滤会有两个明显问题一是最近新增的景点没有任何行为数据永远沉底二是热门景点被反复推荐长尾出不来。所以我倾向最后加一个混合层把基于内容的得分和协同过滤的得分做加权同时用一个时间衰减因子来调节。时间衰减公式我常用time_decay exp(-0.03 * days_since_created)这个衰减系数 0.03 大约对应一个景点发布 30 天后热度衰减到接近 40%适合旅游这种新景点热度短经典景点长尾长的场景。如果你希望新景点的热度窗口更长把系数调到 0.01如果希望当周上新有强曝光调到 0.05。def hybrid_score(user_profile_sim, cf_score, created_days, alpha0.6, beta0.4, decay0.03): 混合算法内容相似度和协同过滤分数加权再乘时间衰减 content_part alpha * user_profile_sim cf_part beta * cf_score time_decay np.exp(-decay * max(created_days, 0)) return (content_part cf_part) * time_decayalpha0.6、beta0.4表示在新用户上没有行为数据时cf_score 为 0靠内容相似兜底在有充分历史行为的成熟用户上如果你希望协同过滤权重更大可以把alpha和beta对调。这个混合层的输出不要直接返回最好再过一个过滤规则按用户当前所在城市过滤掉距离过远的推荐项、按门票价格过滤掉超出预算的项。理由很简单——推荐系统最好的结果不是最相似而是最合适且能成行。4. 系统模块怎么搭Web 服务、数据库设计与 API 接口4.1 架构割裂是失败源头不要算法一套、Web 一套很多毕设把推荐算法写在 Jupyter Notebook 里然后 Web 端自己另外写一套逻辑最后两边没法对接答辩时只能靠截图撑场。正确做法是算法模块做成独立的 Python 包Web 层只负责把 HTTP 请求参数传进来、从推荐引擎拿结果、拼装 JSON 返回。接口和算法之间只暴露一个recommend_for_user(user_id, topn, filters)函数内部细节封住。这样做的价值在于你用 Flask 做演示时不用每次都重新计算相似度矩阵。相似度矩阵可以在服务启动时加载进内存或者用 Redis 缓存。切换成 Django 也好换成 FastAPI 也好推荐引擎一毫秒都不用动。4.2 Flask 最小可跑服务路由、请求参数与结果序列化这里给一个生产可用至少演示够用的 Flask 服务骨架。重点是路由设计、参数校验和结果结构。不建议用 Django除非你的项目里还需要后台管理界面否则 Flask 足够展示。from flask import Flask, request, jsonify app Flask(__name__) # 省略引擎加载逻辑 # recommend_engine load_engine(...) app.route(/api/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, typeint) topn request.args.get(topn, default10, typeint) city request.args.get(city, default, typestr) max_price request.args.get(max_price, default9999, typeint) if not user_id: return jsonify({code: 400, msg: user_id is required}), 400 # 调用推荐引擎统一接口 recs recommend_engine.get_recommendations( user_iduser_id, topntopn, citycity, max_pricemax_price ) # 把内部数据结构转成 JSON 友好的字典列表 items [ {poi_name: r[name], score: round(r[score], 4), tags: r[tags], city: r[city]} for r in recs ] return jsonify({code: 0, data: items}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)注意两点user_id用了typeint做隐式强转不用再写一堆if not isinstance(user_id, int)的校验返回结构用统一的code字段code0代表成功非 0 为业务错误这样前端只需要判断一次状态码。app.run(debugFalse)是必须的加了 debug 会自动开启代码热重载和交互式调试页演示时万一抛个异常评委能看到完整回溯栈这是最伤印象的。4.3 数据库表结构用户表、POI 表和用户行为表的最小设计算法生成推荐结果但用户、行为、POI 明细都得落到数据库里。推荐系统里的数据表至少三张user、poi、user_behavior。注意一个常被忽略的规范表名用单数还是复数不影响功能但全项目必须统一别一章代码里user另一章又users。CREATE TABLE user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, preferences VARCHAR(255), -- 用户自选的偏好标签如 亲子|历史|美食 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE poi ( poi_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50), score DECIMAL(3,1), price DECIMAL(8,2), tags VARCHAR(255), -- 标签用 | 分隔跟 CSV 保持一致 created_days INT DEFAULT 0, -- 可计算字段避免每次查现算 detail_json TEXT -- 详情页结构不稳定的都丢这里 ); CREATE TABLE user_behavior ( behavior_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, poi_id INT NOT NULL, behavior_type TINYINT, -- 1收藏 2点击 3预订 score INT DEFAULT 1, -- 冗余字段方便直接做矩阵 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) );表设计里的score不属于三范式但这是我推荐保留的冗余字段——它让你不必每次计算行为分数时都动态判断behavior_type对应 1 分还是 3 分直接查score就能构矩阵。poi.created_days同理在导入数据时算好存进去混合排序时直接取出来用秒杀在推荐接口里现场DATEDIFF(NOW(), created_at)。另外一个设计原则detail_json存无法结构化的字段避免每个分析场景都改表结构。4.4 别忘了可视化推荐系统的效果需要一个看得见的界面论文里系统实现部分评委首先看界面截图。你不一定要做很漂亮的前端但至少需要一个能展示三件事的页面用户输入自己的 ID 或偏好系统实时返回推荐列表并且标注每个推荐依据是什么基于内容相似、相似用户也去过、新景点热门——推荐理由的可解释性最能体现系统完成度。如果时间紧建议用 Flask 自带模板语言 Jinja2 搭配 Bootstrap 写两个页面就够了一个/首页展示热门景点一个/recommend页面提交 user_id 看个性化结果。前端不要写 React你会被 Node 依赖的版本问题耗掉大量时间。个中血泪后面避坑章节细说。5. 三个最容易翻车的地方冷启动、相似度失真与数据膨胀5.1 冷启动新用户第一次打开首页系统推什么现象一个刚注册的用户没有浏览、收藏、预订过任何景点UserCF 和 ItemCF 全部失效接口返回空列表前端一片空白。原因整个推荐链路都依赖行为数据而新用户的行为数据为零。这是所有从零搭建推荐系统的项目最先撞上的墙不是你的代码写错了而是算法的前置条件没满足。解决给推荐引擎加一个冷启动兜底层。具体做法是在用户没有行为或行为少于 3 条时直接切换到热门榜召回。热门榜不是简单地按景点评分排建议使用hots 0.6 * avg_score 0.4 * popularity_log其中popularity_log log1p(浏览数 预订数)表示热度取对数避免个别爆款把其他长尾景点全挤掉。同时在论文中把冷启动策略明确描述为热门推荐 基于注册时选择兴趣标签的粗粒度召回这个设计就能让评委看到你对边界情况有认知。5.2 相似度矩阵全部变成 NaN推荐结果一塌糊涂现象协同过滤算出来的相似度矩阵是NaN、inf或者全 0推荐接口每次都返回默认热门榜但你本地测试时模型却正常。原因三种常见情况——一是用户行为矩阵里存在整行全 0 的用户余弦相似度分母为 0 导致 NaN二是矩阵稀疏度太高两个用户恰好没有共同交互的景点相似度真就是 0但那不代表不相似第三种是我踩过的坑数据里混入了爬虫抓到的重复景点同一个景点两条名字行为矩阵出现列重复算出来的相似度严重失真。解决在构建矩阵前做一次实体对齐把重复景点名合并最简单是用字符串模糊匹配difflib.SequenceMatcher相似度 0.9 就视为同一实体。然后在计算相似度时分母加平滑项denom 1e-9并检查结果是否有 NaN。最后建立行为数据的最低门槛如果所有用户平均行为数不到 5 条放弃 UserCF直接走基于内容的推荐加规则过滤。别硬撑着跑协同过滤效果只会让你在论文里写出观感不佳的效果截图。5.3 跨浏览器访问异常和编码问题比算法问题还磨人现象同一个 Flask 服务Chrome 访问正常换成 Firefox 或国产浏览器页面中文全变乱码或者接口 JSON 里返回的中文是\uXXXX。原因最常见的原因是 Flask 的默认 JSON 序列化会把中文转成 Unicode 转义序列浏览器端能解但显示成源码时就一团糟。同理CSV 文件如果用utf-8而不是utf-8-sig写入Excel 导入时中文开头会莫名缺字。解决在 Flask 初始化时设置app.json.ensure_ascii False同时在生成响应时加mimetypeapplication/json; charsetutf-8两个配套才能保证跨浏览器和跨平台的表现一致这一步也能避免你论文演示时换台电脑就翻车。另外写文件路径时统一用正斜杠/或者os.path.join看起来是小事Windows 和 Linux 交叉部署时就靠这个保命。6. 离线评估与上线前最后一件事别慌着加功能先做指标基线6.1 推荐效果不能靠肉眼判断用 Precision 和 Recall 说话系统能跑通了下一步是论文里最需要的实验结果章节。很多学生在这个位置只会放两张截图说推荐效果良好这远远不够。推荐系统的离线评估可以把用户已有行为按时间切分用前 80% 的行为做训练后 20% 做验证统计推荐列表命中验证集的比例计算公式如下。def evaluate_recall_at_k(rec_lists, held_out): hits 0 total sum(len(pois) for pois in held_out.values()) for uid, recs in rec_lists.items(): truth set(held_out.get(uid, [])) hits len(set(recs) truth) return hits / total if total else 0.0 def evaluate_precision_at_k(rec_lists, held_out): hits_per_user [] for uid, recs in rec_lists.items(): truth set(held_out.get(uid, [])) hits_per_user.append(len(set(recs) truth) / len(recs) if recs else 0.0) return sum(hits_per_user) / len(hits_per_user) if hits_per_user else 0.0RecallK 和 PrecisionK 是最常被要求的两个指标。对旅游推荐来说K10时 Recall 能到 20% 以上、Precision 在 15%-30% 之间都算基线有效。如果你发现 Recall 太低优先检查是不是数据太稀疏而不是换更复杂的算法——这一点值得反复强调。顺便把不同 K 值下的表现画成折线图比写一段空泛的效果不错有说服力得多。6.2 做一个后悔药一键重新生成相似度矩阵的命令推荐系统开发过程里你会反复调参、换数据、重新清洗最烦的是每次都要手动跑一遍全链路。我最后强烈建议你写一个rebuild.sh命令把清洗、向量化、相似度计算、导入数据库四步串成一条命令。这不是做不做的问题是你演示前一晚必会感谢自己留了这条后路。#!/bin/bash # 一键重建推荐数据链路包含旧数据备份 set -e # 1. 备份旧的相似度矩阵防止调参调挂了没法回滚 cp -f data/poi_similarity.pkl data/poi_similarity.pkl.bak || true # 2. 重新执行数据清洗与特征工程 python scripts/01_clean_poi.py --input data/poi_raw.csv --output data/poi_clean.csv # 3. 重新训练/计算相似度矩阵 python scripts/02_build_sim.py --input data/poi_clean.csv --output data/poi_similarity.pkl # 4. 把最新结果导入数据库覆盖旧推荐数据 python scripts/03_import_to_mysql.py --input data/poi_clean.csv echo rebuild done.set -e的意思是任何一个环节失败整个脚本立即停止不会留下以为更新了、实际中间断掉的残缺数据。这个习惯救过我很多次——一次是爬虫抓回来只有 30 条有效数据的脏数据集脚本第二环节直接报错退出我才意识到清洗阶段把过多景点误删了而不是带着脏数据跑完整条链路后才发现结果全错。cp .bak是后悔药建议所有覆写操作的脚本都留这一步成本极低收益极高。最后落一个习惯每次调完推荐参数先跑一遍离线评估脚本再刷新线上看效果不要跳过评估直接肉眼验收。推荐系统这种不确定性很强的工程感觉会骗人只有指标不会。希望这篇从数据到接口再到避坑的完整拆解能帮你把这条路走顺。本文还有配套的精品资源点击获取