简介:这是一份基于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', encoding='utf-8') # 行是用户,列是景点,值是评分 user_item_matrix = ratings.pivot_table( index='user_id', columns='scenic_id', values='rating' ).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_k=30, min_sim=0.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(key=lambda x: x[1], reverse=True) 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_k | 20 ~ 50 | 邻居数量。数据稀疏时调小到 20,数据密集可调到 50 |
min_sim | 0.1 ~ 0.3 | 低于该相似度的用户不参与预测。调高则推荐更保守 |
score阈值 | 3.5 | 预测分大于等于 3.5 才进入候选列表 |
min_sim是很容易被忽略的参数。如果设成 0,所有用户都成为邻居,相似度为负的用户也会贡献预测值,推荐结果会被带偏。我一般从 0.1 起步,看推荐列表的多样性再微调。
生成最终推荐列表时,不再预测每个景点的分数,而是对“目标用户未评分的全部景点”批量预测,再取 TopN:
def recommend(user_id, matrix, n=10): 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(), key=lambda x: x[1], reverse=True)[:n] return [scenic_id for scenic_id, _ in top]这个版本为了可读性牺牲了速度。实际毕设项目里景点最多几千个,循环一次完全能接受;如果景点到几万量级,就要改成向量化矩阵运算了。
2.4 冷启动救场:基于标签的内容召回
协同过滤的经典盲区是冷启动:新用户没有任何评分,相似度算不出来,推荐列表为空。旅游系统的常见做法是用景点标签做内容召回兜底。
景点表里一般有tags字段,例如"杭州,西湖,自然风光,亲子"。注册时让用户勾选三个偏好标签,新用户第一次请求推荐就按标签匹配景点:
def cb_recommend(user_prefer_tags, scenic_df, n=10): # 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, axis=1) top = scenic_df.sort_values('_score', ascending=False).head(n) return top['id'].tolist()老用户则可以走混合推荐:协同过滤结果和内容召回结果按权重合并,权重可以做成配置项。常见做法是内容比分除以最大得分做归一化,再按权重相加:
def mix_recommend(cf_list, cb_list, cf_weight=0.7, limit=10): 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(), key=lambda x: x[1], reverse=True)[: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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB;几个字段设计上的讲究:
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 TABLE:
ALTER 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.py,Web 入口用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_threshold=0.15, top_k_user=30, cf_weight=0.7, cb_weight=0.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, limit=limit * 2) else: cf_result = rec.cf_recommend(user_id, limit=limit * 2) cb_result = rec.cb_common(user_id, limit=limit * 2, conn=conn) items = rec.mix(cf_result, cb_result, limit=limit) # 城市过滤放在混排之后,保证数量够;如果过滤后不足再补热门 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(debug=True, port=5000)这里牵扯相关热词“数据库增删改查”,逻辑里已包含查询和写入操作。参数的作用如下:
sim_threshold=0.15控制了相似度邻居的最低门槛,值越大邻居越挑剔,推荐结果更集中也可能更少。limit * 2是因为混合之后还要做城市过滤,如果只取limit个再过滤,很可能剩余不足;多取一倍给过滤留余量。cf_weight=0.7表示老用户以协同过滤为主,内容召回只做补充;新用户自动切到cf_weight=0。
接口调通后这样验证:
curl -b cookies.txt 'http://127.0.0.1:5000/api/recommend?city=杭州&limit=10'返回的 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, k=10): 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我一般会以k=10为标准:精确率 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是否设置过小,比如小于 20;cp_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 节的评估脚本,对比精确率和覆盖率两个维度,整个推荐系统的调优闭环就完整了。
本文还有配套的精品资源,点击获取