简介这是一份基于Python的智能旅游推荐系统毕业设计项目面向计算机相关专业学生用于毕业设计、课程设计或项目实战。系统采用Python3.7作为后台框架前端为HTML页面搭配MySQL数据库使用PyCharm打开并安装依赖即可运行。资源共797个文件压缩包约20.19MB包含45个Python源码文件、53个Vue组件、53个HTML页面、53个CSS样式、164个JS交互脚本及SQL数据库脚本并附带安装与运行批处理便于快速启动部署。目前已有132人学习下载。项目经严格调试可稳定运行功能覆盖旅游景点推荐、用户管理、线路规划等模块界面美观、操作便捷。读者可借助完整源码深入理解前后端交互、数据库表设计以及推荐算法实现思路适合作为毕业设计答辩项目或Python进阶学习参考。1. 旅游推荐系统毕设里最容易做成“算法单薄”的题目毕设选“基于Python的智能旅游推荐系统”的人很多因为它能展示全栈能力Web页面、数据库、算法、可视化一次凑齐。但多数人只做到“把数据查出来按评分排序”这就浪费了最核心的卖点。这个题目的正确打开方式是把推荐算法做成真正的推荐引擎——用户登录后看到的是“和你偏好相似的游客也去了这些地方”而不是单纯的排行榜。下面按我自己的实现路径来讲算法、数据库、Web接口、离线验证四层都覆盖你照着这套思路拿附件里的源码也能看清每一段在干什么。2. 协同过滤推荐算法先算相似度再谈精准与冷启动2.1 旅游场景下选用户协同还是物品协同推荐系统里最常用两类协同过滤基于用户的 User-based CF 和基于物品的 Item-based CF。旅游景点这套数据我一般建议以 User-based CF 为主。原因很直白毕设项目的景点数量通常在几百到一两千而用户可能只有几百人。用 Item-based CF 时需要计算“景点与景点”的共现矩阵一个景点被访问次数本来就不多两两共现的次数更少算出来的相似度噪声很大。User-based CF 找的是行为模式相近的人只要两个用户有若干个共同评价过的景点皮尔逊系数就能给出方向感对稀疏数据更友好。而且解释起来也顺“和你偏好相近的人去了杭州、成都”无论是论文里的推荐原理图还是答辩时的口头解释都比基于物品的版本更容易讲清楚。这个选择不是绝对的。如果你手里的景点数据超过 5000 条且用户行为足够密再切到 Item-based CF 也不迟。作为毕业设计User-based CF 加上一层基于标签的内容召回已经能覆盖绝大多数功能点和答辩问题。2.2 构建用户-景点评分矩阵的预处理算法第一步是把关系型数据变成矩阵。推荐系统拿到的一般是train_ratings.csv列名至少要有user_id, scenic_id, rating其中 rating 是 1 到 5 的整数。用 pandas 的透视表一行代码就能转成矩阵import pandas as pd import numpy as np # 读入训练集user_id, scenic_id, rating 三列 ratings pd.read_csv(data/train_ratings.csv, encodingutf-8) # 行是用户列是景点值是评分 user_item_matrix ratings.pivot_table( indexuser_id, columnsscenic_id, valuesrating ).fillna(0) print(user_item_matrix.shape) # 输出类似 (482, 356) print(user_item_matrix.head(3))这里的fillna(0)有一个必须注意的坑0 只是占位符表示“用户没有评过分”它并不代表真实的 1 分。如果直接用 0 参与均值计算或者相似度计算会把没评分的景点当成“讨厌的景点”推荐结果被严重拉偏。因此后面所有相似度计算里都要先做 mask只挑出双方都有评分的位置参与计算。顺手可以把矩阵存成.npy文件每次启动项目不用重新拼接np.save(cache/user_item.npy, user_item_matrix.values)这样 Web 服务重启时加载一个数组比重新执行 SQL 查询快一个数量级。2.3 皮尔逊相似度与 TopN 推荐实现皮尔逊相关系数是 User-based CF 里最常用的相似度度量它消除了用户打分尺度差异。比如 A 习惯打 3 分算“不错”B 习惯打 5 分才算“不错”比较原始分没有意义但减掉各自均值后趋势就一致了。核心代码可以这样写def pearson_corr(u_vec, v_vec): # 只取双方都大于0的位置 mask (u_vec 0) (v_vec 0) if mask.sum() 2: return 0.0 u u_vec[mask] v v_vec[mask] u_center u - u.mean() v_center v - v.mean() denominator np.sqrt((u_center ** 2).sum() * (v_center ** 2).sum()) if denominator 0: return 0.0 return np.dot(u_center, v_center) / denominator有了相似度之后预测用户对某个景点的评分最常见做法是“相似用户加权和”找到和目标用户最像的 30 个邻居用相似度乘以邻居对该景点的评分累加后除以相似度总和得到预测分数。实现如下def predict_score(user_id, scenic_id, matrix, top_k30, min_sim0.1): if user_id not in matrix.index or scenic_id not in matrix.columns: return 0.0 target_vec matrix.loc[user_id].values # 已经评过分的直接返回原分不再预测 if matrix.loc[user_id, scenic_id] 0: return matrix.loc[user_id, scenic_id] similar_scores [] for other_id in matrix.index: if other_id user_id: continue other_vec matrix.loc[other_id].values sim pearson_corr(target_vec, other_vec) if sim min_sim: similar_scores.append((other_id, sim)) # 按相似度取前 k 个邻居 similar_scores.sort(keylambda x: x[1], reverseTrue) neighbors similar_scores[:top_k] total_sim sum(sim for _, sim in neighbors) if total_sim 0: return 0.0 weighted_sum 0.0 for other_id, sim in neighbors: if matrix.loc[other_id, scenic_id] 0: weighted_sum sim * matrix.loc[other_id, scenic_id] return weighted_sum / total_sim两个关键参数的作用要搞清楚参数建议值作用与调整方向top_k20 ~ 50邻居数量。数据稀疏时调小到 20数据密集可调到 50min_sim0.1 ~ 0.3低于该相似度的用户不参与预测。调高则推荐更保守score阈值3.5预测分大于等于 3.5 才进入候选列表min_sim是很容易被忽略的参数。如果设成 0所有用户都成为邻居相似度为负的用户也会贡献预测值推荐结果会被带偏。我一般从 0.1 起步看推荐列表的多样性再微调。生成最终推荐列表时不再预测每个景点的分数而是对“目标用户未评分的全部景点”批量预测再取 TopNdef recommend(user_id, matrix, n10): scores {} for scenic_id in matrix.columns: if matrix.loc[user_id, scenic_id] 0: continue scores[scenic_id] predict_score(user_id, scenic_id, matrix) top sorted(scores.items(), keylambda x: x[1], reverseTrue)[:n] return [scenic_id for scenic_id, _ in top]这个版本为了可读性牺牲了速度。实际毕设项目里景点最多几千个循环一次完全能接受如果景点到几万量级就要改成向量化矩阵运算了。2.4 冷启动救场基于标签的内容召回协同过滤的经典盲区是冷启动新用户没有任何评分相似度算不出来推荐列表为空。旅游系统的常见做法是用景点标签做内容召回兜底。景点表里一般有tags字段例如杭州,西湖,自然风光,亲子。注册时让用户勾选三个偏好标签新用户第一次请求推荐就按标签匹配景点def cb_recommend(user_prefer_tags, scenic_df, n10): # user_prefer_tags: [古镇, 自然风光] def tag_score(row): scenic_tags set(row[tags].split(,)) if not scenic_tags: return 0.0 return len(set(user_prefer_tags) scenic_tags) / len(scenic_tags) scenic_df[_score] scenic_df.apply(tag_score, axis1) top scenic_df.sort_values(_score, ascendingFalse).head(n) return top[id].tolist()老用户则可以走混合推荐协同过滤结果和内容召回结果按权重合并权重可以做成配置项。常见做法是内容比分除以最大得分做归一化再按权重相加def mix_recommend(cf_list, cb_list, cf_weight0.7, limit10): mix_scores {} max_cf max(cf_list.values()) if cf_list else 1 max_cb max(cb_list.values()) if cb_list else 1 for item_id, score in cf_list.items(): mix_scores[item_id] cf_weight * score / max_cf for item_id, score in cb_list.items(): mix_scores[item_id] mix_scores.get(item_id, 0) (1 - cf_weight) * score / max_cb return sorted(mix_scores.items(), keylambda x: x[1], reverseTrue)[:limit]对完全没有行为的新用户把cf_weight置为 0 即可。这段逻辑建议在recsys.py里独立成类Web 层只负责调接口不掺算法细节。3. 数据库设计从用户、景点、行为日志三类表搭出推荐系统的数据底座3.1 为什么不能只建一张评分表很多第一版设计只有一张rating表字段是user_id, scenic_id, score。它的问题在于系统无法区分“用户没看过这个景点”和“用户看过但不喜欢”而协同过滤恰恰依赖这种区分才能计算相似度。此外用户的注册信息、浏览记录、收藏和搜索日志也没有地方放导致冷启动策略完全无法落地。推荐系统的数据库至少分四张表用户表、景点表、评分表、行为日志表。评分表存显式反馈打分行为日志表存隐式反馈浏览、收藏、搜索、预订。两者都要留因为它们承担不同角色评分表直接参与协同过滤训练行为日志表用来做统计召回和权重增强。3.2 建表 SQL 与字段选择说明以 MySQL 为例下面是完整建表脚本字符集统一用utf8mb4避免景点介绍里的 emoji 导致报错CREATE DATABASE IF NOT EXISTS travel_rec DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE travel_rec; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL, password_md5 CHAR(32) NOT NULL, home_city VARCHAR(32) DEFAULT NULL COMMENT 出发地, prefer_tags VARCHAR(128) DEFAULT NULL COMMENT 偏好标签逗号分隔, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB; CREATE TABLE t_scenic ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, city VARCHAR(32) NOT NULL, tags VARCHAR(128) NOT NULL COMMENT 如古镇,自然风光,亲子, lng DECIMAL(9,6) DEFAULT NULL COMMENT 经度, lat DECIMAL(9,6) DEFAULT NULL COMMENT 纬度, hot_score DECIMAL(4,2) DEFAULT 0 COMMENT 热度分冷启动兜底用, KEY idx_city (city), KEY idx_hot (hot_score) ) ENGINEInnoDB; CREATE TABLE t_rating ( user_id INT NOT NULL, scenic_id INT NOT NULL, score TINYINT NOT NULL COMMENT 1-5分, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, scenic_id), KEY idx_scenic (scenic_id) ) ENGINEInnoDB; CREATE TABLE t_behavior ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, scenic_id INT NOT NULL, behavior ENUM(view,collect,search,book) NOT NULL, weight TINYINT DEFAULT 1 COMMENT 隐式反馈权重, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB;几个字段设计上的讲究score用TINYINT而不是INT或VARCHAR范围天然限制在 1 到 5防止脏数据进入算法。prefer_tags用逗号分隔存储违反了第一范式但符合实际业务习惯读取后在 Python 里split(,)即可对毕设项目来说比建标签关联表少写大量 join。t_rating的主键是(user_id, scenic_id)联合主键天然去重用户重复打分时用ON DUPLICATE KEY UPDATE覆盖即可。t_behavior必须建(user_id, created_at)索引因为 Web 首页要查“最近浏览”推荐算法要做“某用户近期行为统计”这个联合索引能让两个查询都走索引。导入数据时如果教程里附带的 SQL 文件直接用 Navicat 等工具导入即可。需要注意点导入前先确认文件编码是 UTF-8否则中文景点名会出现乱码导入后顺手执行几条SELECT COUNT(*)确认行数对得上再进算法。3.3 把行为日志转成训练集SQL 预处理与结构调整浏览器点击“收藏”和“评分”之后业务层应该同时写一条行为日志因为行为数据有时比重打分更真实——用户愿意点收藏说明确实感兴趣。转化为训练集的做法是给每种行为赋权重再按用户、景点聚合成隐式评分UPDATE t_behavior SET weight CASE behavior WHEN view THEN 1 WHEN collect THEN 3 WHEN search THEN 2 WHEN book THEN 5 ELSE 1 END; SELECT user_id, scenic_id, SUM(weight) AS rating FROM t_behavior GROUP BY user_id, scenic_id HAVING rating 0;这段 SQL 的结果可以直接导成train_ratings.csv喂给第 2 节的推荐算法。注意rating此时已经变成 1 到 5 之外的整数预测函数里的“已评分判断”依赖 0所以逻辑不受影响。开发过程中经常要改表结构比如给评分表加“备注”字段或者给景点表加“是否下架”标记。MySQL 的标准做法就是ALTER TABLEALTER TABLE t_rating ADD COLUMN remark VARCHAR(255) DEFAULT NULL; ALTER TABLE t_scenic ADD COLUMN disabled TINYINT DEFAULT 0 COMMENT 1表示下架;改完结构后如果t_scenic增加了disabled字段推荐算法的 SQL 查询要同步加上过滤条件避免把已下架景点推荐出去。这也是毕业设计答辩时常见的追问点数据变更后算法怎么处理。这里的方法并不复杂但能体现出你真正理解了表结构和算法之间的关系。4. Python Web 端实现把推荐算法接进 Flask 可交互页面4.1 算法模块与 Flask 路由的分层上面算法写在recsys.py里数据库层用db.pyWeb 入口用app.py。Flask 路由里不要出现任何算法代码只负责三件事取当前登录用户、拿请求参数、调用recsys返回 JSON 结果。项目结构大致是travel_recommend/ app.py db.py recsys.py template/ static/ data/ train_ratings.csv cache/ user_item.npydb.py里用pymysql做最小封装把连接配置和get_connection()独立出来避免每个路由重复写连接参数。这样做的直接好处是调参的时候只需要改动recsys.py的构造函数参数跟页面逻辑完全解耦。4.2 推荐接口 /api/recommend 的实现与参数设定推荐接口要同时考虑“新用户”和“老用户”两种情况。核心逻辑是先取协同过滤结果再取内容召回结果最后按配置的混合权重合并from flask import Flask, request, jsonify, session from db import get_db from recsys import Recommender app Flask(__name__) app.secret_key your-secret-key rec Recommender( sim_threshold0.15, top_k_user30, cf_weight0.7, cb_weight0.3 ) app.route(/api/recommend, methods[GET]) def recommend(): user_id session.get(user_id) if user_id is None: return jsonify({code: 401, msg: 请先登录}), 401 city request.args.get(city) limit int(request.args.get(limit, 10)) conn get_db() cursor conn.cursor(pymysql.cursors.DictCursor) # 新用户走纯内容推荐老用户走混合推荐 cursor.execute( SELECT COUNT(*) AS cnt FROM t_behavior WHERE user_id%s, (user_id,) ) row cursor.fetchone() if row[cnt] 0: cursor.execute(SELECT prefer_tags FROM t_user WHERE id%s, (user_id,)) user_tags cursor.fetchone()[prefer_tags] cf_result {} cb_result rec.cb_recommend(user_tags, scenic_df, limitlimit * 2) else: cf_result rec.cf_recommend(user_id, limitlimit * 2) cb_result rec.cb_common(user_id, limitlimit * 2, connconn) items rec.mix(cf_result, cb_result, limitlimit) # 城市过滤放在混排之后保证数量够如果过滤后不足再补热门 result [] for scenic_id, score in items: if len(result) limit: break cursor.execute(SELECT name, city, tags FROM t_scenic WHERE id%s, (scenic_id,)) info cursor.fetchone() if info and (city is None or info[city] city): result.append({scenic_id: scenic_id, name: info[name], score: round(score, 4)}) cursor.close() conn.close() return jsonify({code: 0, data: result}) if __name__ __main__: app.run(debugTrue, port5000)这里牵扯相关热词“数据库增删改查”逻辑里已包含查询和写入操作。参数的作用如下sim_threshold0.15控制了相似度邻居的最低门槛值越大邻居越挑剔推荐结果更集中也可能更少。limit * 2是因为混合之后还要做城市过滤如果只取limit个再过滤很可能剩余不足多取一倍给过滤留余量。cf_weight0.7表示老用户以协同过滤为主内容召回只做补充新用户自动切到cf_weight0。接口调通后这样验证curl -b cookies.txt http://127.0.0.1:5000/api/recommend?city杭州limit10返回的 JSON 结构应该类似{code:0, data:[{scenic_id:12,name:西湖,score:4.21}]}。调试时重点观察两点未登录是否返回 401登录用户确实返回了 10 条且每条都有分数。4.3 注册、评分、收藏三个数据入口的联动推荐系统要闭环光有推荐接口不够必须有数据入口持续喂数据。注册上就用表单采集偏好标签为新用户的内容召回打基础app.route(/api/register, methods[POST]) def register(): username request.form.get(username) password request.form.get(password) city request.form.get(city) prefer_tags request.form.get(prefer_tags) cursor.execute( INSERT INTO t_user (username, password_md5, home_city, prefer_tags) VALUES (%s, MD5(%s), %s, %s), (username, password, city, prefer_tags) )评分接口写t_rating和t_behavior两张表一次请求完成两类数据写入app.route(/api/rate, methods[POST]) def rate(): user_id session.get(user_id) scenic_id int(request.form.get(scenic_id)) score int(request.form.get(score)) cursor.execute( INSERT INTO t_rating (user_id, scenic_id, score) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE score%s, (user_id, scenic_id, score, score) ) cursor.execute( INSERT INTO t_behavior (user_id, scenic_id, behavior) VALUES (%s, %s, rate), (user_id, scenic_id) ) conn.commit()这里收藏和浏览页面上也都调用同一个t_behavior插入逻辑只是behavior参数不同。设计细节是 “rate” 和 “collect” 要区分开这样第 3.3 节的CASE权重转换才有意义。如果只写一张评分表而丢掉行为日志后续的基于权重的统计召回和热门兜底都无从谈起。5. 离线评估与参数调优让推荐系统从“能跑”到“能用”5.1 用精确率召回率验证推荐列表毕设答辩时最常被问“你怎么证明推荐结果是有效的”。网上只展示页面截图不够离线评估是最有说服力的验证方式。做法是把行为数据按用户切分80% 做训练集20% 做测试集用训练集计算推荐再判断推荐结果里有多少命中了测试集中的景点。def evaluate(test_ratings, recommend_func, k10): hit_total, rec_total, real_total 0, 0, 0 for user_id, group in test_ratings.groupby(user_id): truth set(group[scenic_id]) rec_items recommend_func(user_id, k) hit_total len(set(rec_items) truth) rec_total len(rec_items) real_total len(truth) precision hit_total / rec_total recall hit_total / real_total return precision, recall我一般会以k10为标准精确率 8% 到 15% 属于正常水平因为旅游景点基数大而测试样本少召回率在 20% 以上就算表现不错。这个指标不需要多高重点是要能说明“调整某个参数后两个指标呈现什么趋势”。答辩时说出这套方法比单纯贴截图有说服力得多。5.2 覆盖率与流行度偏差答辩前的最后检查除了精确率召回率还有一个指标很能体现问题意识覆盖率。它衡量推荐列表覆盖了多少不同景点如果系统永远推荐最热门的 30 个景点精确率可能还不错但覆盖率会很低产品上没有差异化可言。计算方式很简单def coverage(recommend_map, all_items): return len(set(x for lst in recommend_map.values() for x in lst)) / float(len(all_items))覆盖率低于 15% 的时候说明推荐列表被热门景点霸榜了。优先检查两处top_k_user是否设置过小比如小于 20cp_weight是否设成 1.0 导致内容召回完全没起作用。还有一个常见坑评分矩阵里没被评过分且未被推荐的景点在预测阶段会被忽略导致推荐结果总是局限在热门集合里。遇到这种情况可以在预测分数上加一个极小的热度扰动分例如final_score pred_score hot_score * 0.01让冷门且有潜力的景点有出头机会。最后配合一个调参小技巧相似度矩阵的计算开销在用户量大时非常明显每次 Flask 启动都重算是浪费。将用户相似度矩阵提前算好并缓存在本地文件里启动时直接加载增量更新时只在有新评分后重建一次import os import numpy as np if os.path.exists(cache/sim.npy): rec.sim_matrix np.load(cache/sim.npy) else: rec.build_sim_matrix() np.save(cache/sim.npy, rec.sim_matrix)加上这个缓存服务重启后接口响应时间能从几百毫秒降到毫秒级。而且这个改动不涉及算法本身属于工程优化毕设文档里单独列一个小节写“推荐延迟优化”非常加分。拿到这个缓存文件后调参数时直接改min_sim和top_k再跑 5.1 节的评估脚本对比精确率和覆盖率两个维度整个推荐系统的调优闭环就完整了。本文还有配套的精品资源点击获取