基于用户协同过滤的旅游景点推荐系统实现与优化
简介面向高校计算机专业学生与旅游信息化开发者这是一份完整的Python协同过滤旅游推荐系统毕业设计说明书docx文档。方案针对旅游信息过载问题围绕个性化推荐展开涵盖景点搜索查询、热门景点推荐、基于用户行为的协同过滤推荐、途经景点与乘车方式推荐等模块并在数据处理环节使用Python爬虫采集旅游数据、Kettle完成预处理、MySQL负责存储管理同时设计了可视化大屏。资源共1个docx文件压缩包大小8.64MB主体为48页本科毕业设计说明书包含中英文摘要、系统设计与实现思路适合作为相关课程设计、毕业设计的参考模板。目前已有547人学习具备一定参考热度可直接对照选题方向、技术路线与功能模块结构进行借鉴。1. 信息过载的旅游场景为什么把用户协同过滤作为推荐主线2019 年国内旅游人次超过 60 亿线上的局面是景点、攻略、推荐位都不缺缺的是把用户真正想去的结果捞出来的能力。这个毕设项目压在三个工程问题上数据从哪来、行为怎么记录、协同过滤推荐怎么跑。最值得玩味的是选型——没用内容推荐也没用基于物品的协同过滤ItemCF而是基于用户的协同过滤UserCF初看像是新手的默认选择。跑完会发现收藏、评论、点击这些行为信号增量快UserCF 的相似度矩阵每天重算都有变化正好贴合旅游用户隔几天看看新去处、出行决策频繁变化的节奏。整条链路不依赖重框架纯 Python Django MySQL 就能复现适合正在搭景点、餐饮或本地生活推荐的人。下面按数据管道、算法实现、Web 端功能和验证方法四条线拆开讲哪里能直接抄哪里是坑都会说明。2. 数据管道搭建Python 爬虫采集、Kettle 清洗与 MySQL 落库2.1 采集层设计requests BeautifulSoup 怎么把景点数据拉下来这个项目里景点、城市、路线数据不是手工录入的而是用 Python 爬虫采集的。PyCharm 里配好 Python 3.x 环境后整个采集链路一天之内可以跑通。抓取景点列表页的简化实现如下import requests from bs4 import BeautifulSoup import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_scenic_pages(base_url, pages10): results [] session requests.Session() session.headers.update(HEADERS) for page in range(1, pages 1): try: resp session.get(f{base_url}?page{page}, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) # 以列表项为单位提取景点卡片 for item in soup.select(.scenic-item): name item.select_one(.name).text.strip() city item.select_one(.city).text.strip() heat item.select_one(.heat).text.strip() results.append({name: name, city: city, heat: heat}) except requests.RequestException as e: print(fpage {page} failed: {e}) continue time.sleep(1) # 控制抓取频率避免给目标站点造成压力 return resultsrequests.Session 在这里是必需的它能复用 TCP 连接抓多页时比每次新建连接快不少。timeout 设成 10 秒超过就放弃当前页避免某个慢接口把整个采集任务卡死。time.sleep(1) 是给目标服务器的基本缓冲也是对数据源的保护。select 那几行依赖目标页面的 CSS 选择器实际写的时候先用浏览器开发者工具确认 .scenic-item 这些 class 是否存在换站点时改选择器即可。项目里最终入库的字段不只是名字和热度还包括评分、简介、经纬度采集时尽量一次抓全否则后面 Kettle 清洗时还要回填代价更高。采集的主要内容字段如下字段来源选择器入库目标备注景点名称.namescenic.name去重时作为主键之一城市.cityscenic.city用于路线推荐和地域分析热度.heatscenic.heat热门推荐排序依据评分.scorescenic.score后续融合排序用简介.introscenic.intro详情页展示提示爬虫只采集公开数据上线前先确认目标站点是否允许抓取。毕设和自用没问题但不要用来做商业化数据源。2.2 清洗层Kettle 转换步骤比脚本更适合可审计的数据预处理数据从网页抓下来通常带着全角空格、空字段、重复记录。这套项目没有在 Python 里做一次性的 pandas 清洗而是选了 Kettle。我见过不少项目用 pandas 一把梭但 Kettle 的优势在于每一步都是可视化的跑完可以看每个步骤进出多少条记录。对毕设来说能说清楚数据是怎么变的比写得快更重要。Kettle 里常见的转换链路是文本文件输入或表输入读入爬虫导出的 CSV 或日志表。字段选择只保留需要的列比如景点名、城市、热度、评分。排序记录按城市和景点名排序为去重做准备。去除重复记录按景点名和城市做去重避免同一个景点被抓了多次。表输出把处理结果写入 MySQL。这个流程里最容易踩坑的是排序和去重的顺序。Kettle 的「去除重复记录」要求数据先按去重字段排序否则相同记录不相邻去重会漏掉。常见做法是先「排序记录」按景点名和城市代码排再挂「去除重复记录」最后接「表输出」。字段选择这一步也值得多说一句爬虫抓下来的原始字段可能有十几个但入库只需要其中几个提前裁剪列可以减少后续转换步骤的内存开销也避免把脏字段一直带到最后。2.3 存储层MySQL 表结构设计与行为表的定位清洗后的数据落到 MySQL。核心表里景点表是内容数据收藏表和评论表是行为数据。推荐算法只消费行为表这个边界一开始就要切开否则后面写 SQL 会越写越乱。核心表结构可以简化成下面这样CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE scenic ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(128) NOT NULL, city VARCHAR(64), heat INT DEFAULT 0, score DECIMAL(3,1), intro TEXT, lng DECIMAL(10,6), lat DECIMAL(10,6) ); CREATE TABLE favorite ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, scenic_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_scenic (scenic_id) );user 表不存明文密码password_hash 字段存的是 Django 的 make_password 输出。favorite 表是推荐系统的核心输入user_id 和 scenic_id 都要建索引算法读行为数据时走 idx_user热门统计时走 idx_scenic。scenic 表里的 heat 可以直接用于热门景点推荐score 字段在后续做融合排序时才有用前期可以留空。created_at 要保留这个字段在协同过滤里看似用不上但做时间衰减和近期偏好时会非常关键如果一开始不建后面想搞「最近 30 天收藏过的景点」就要改表。3. 基于用户的协同过滤相似度计算与 TopN 推荐实现3.1 从收藏行为构造 user-item 矩阵这个系统把用户对景点有没有行为作为唯一信号源没有用评分制。很多推荐系统科普文都从评分矩阵开始讲但实际项目里显式评分很少收藏和点击才是常见行为。构造矩阵时只用记录「收藏过或没收藏过」0/1 矩阵就够了。从 MySQL 读出行为并构造成 Python 字典的常见写法如下import pymysql def load_user_favorites(): conn pymysql.connect( host127.0.0.1, userroot, password123456, databasetravel_recommend, charsetutf8mb4 ) user_items {} try: with conn.cursor() as cursor: cursor.execute(SELECT user_id, scenic_id FROM favorite) rows cursor.fetchall() for user_id, scenic_id in rows: user_items.setdefault(user_id, set()).add(scenic_id) finally: conn.close() return user_itemsuser_items 的结构是 {user_id: {scenic_id, ...}}每个用户对应一个景点集合。用 set 而不是 list是为了后续计算交集和差集时直接做集合运算。fetchall 在数据量不大时没问题这个场景一般几千到几万行一次性读入内存完全够用。如果收藏表到了十万行以上就要考虑分页读取或者只在内存里保留有行为的活跃用户否则矩阵会很稀疏相似度计算结果也基本没有区分度。3.2 相似度计算余弦相似度与皮尔逊系数的取舍UserCF 的关键是计算用户之间的相似度。在 0/1 行为矩阵下余弦相似度可以写成集合形式两个用户共同收藏的景点数除以各自收藏数的几何平均。代码很直接import math def cosine_similarity(user_a_items, user_b_items): intersection len(user_a_items user_b_items) if intersection 0: return 0.0 denominator math.sqrt(len(user_a_items) * len(user_b_items)) return intersection / denominator这个实现把余弦相似度换算成集合运算省去显式的向量构建理解成本也低。denominator 一定不能为 0调用前先确认用户至少有一条收藏记录。共同收藏数量为 0 时直接返回 0避免对小分母做除法的风险。有人会问为什么不用皮尔逊相关系数。皮尔逊会先减去用户均值再计算相关性能消除用户打分尺度不同带来的偏差但这里的行为是 0/1 收藏每个用户的均值其实就是收藏占比扣掉之后剩下的信息量很少效果和余弦不会有质的差别。Jaccard 相似度在交互稀疏时更稳因为它只关注交集占并集的比例对收藏数量悬殊的用户更公平。三种方式取舍如下相似度计算口径适合场景稀疏数据表现余弦相似度交集 / 几何平均行为是 0/1 收藏时效果好一般皮尔逊相关系数去均值后的协方差归一化显式评分数据一般Jaccard 相似度交集 / 并集行为非常稀疏更稳3.3 为你推荐与猜你喜欢TopN 生成与冷启动兜底有了用户相似度推荐流程就固定了先找到和目标用户最相似的 K 个用户再把这些用户收藏过、但目标用户没收藏的景点按相似度加权汇总最后按权重排序取前 N 个。完整实现如下def recommend_for_user(user_id, user_items, similarity_fn, k10, topn5): target_items user_items.get(user_id, set()) if not target_items: return [] # 无行为用户交给热门推荐兜底 # 1. 计算与所有其他用户的相似度 scored [] for other_id, other_items in user_items.items(): if other_id user_id: continue sim similarity_fn(target_items, other_items) if sim 0: scored.append((other_id, sim)) scored.sort(keylambda x: x[1], reverseTrue) # 2. 用最相似的 k 个用户做加权聚合 candidate_score {} for other_id, sim in scored[:k]: for scenic_id in user_items[other_id]: if scenic_id in target_items: continue # 过滤已收藏 candidate_score[scenic_id] candidate_score.get(scenic_id, 0.0) sim # 3. 按得分排序取 topn ranked sorted(candidate_score.items(), keylambda x: x[1], reverseTrue) return [scenic_id for scenic_id, _ in ranked[:topn]]k 是参与聚合的相似用户数一般取 10 到 30topn 是最终推荐数量这个系统里「为你推荐」取 5 个是合理的。k 太小推荐结果会被极少数强相似用户主导k 太大低相似度用户的收藏会稀释高分项。调试时先固定 topn一个参数一个参数试 k看推荐列表里的景点是否和用户已收藏景点属于同一类。如果用户没有任何收藏行为UserCF 算不了这里必须做冷启动兜底。系统里的热门景点推荐就是对 favorite 表做 group by 统计按收藏人数倒序取前 N 个作为新用户看到的默认推荐。另一个见效的做法是给相似度加一个先验如果两个用户共同收藏了同一城市的景点直接在相似度上加一个小常数。这个改动不需要改矩阵结构但对旅游场景很有效因为城市偏好是用户出行决策里最稳定的信号。提示相似度不需要每次请求都重算。用户行为是增量写入的常见做法是每 6 小时或每天凌晨用定时任务重算一次相似度并缓存线上推荐直接读缓存。4. Django 模块实现登录注册、行为埋点与可视化大屏4.1 Django 登录注册与密码存储Web 端用 Django 实现。Django 自带认证框架注册时只需要做一次 make_password登录时用 authenticate 校验没必要自己写哈希算法。注册视图的常见写法from django.contrib.auth.hashers import make_password from django.contrib.auth import authenticate, login from django.shortcuts import render, redirect def register(request): if request.method POST: username request.POST.get(username) password request.POST.get(password) # password 不能直接入库先做哈希 User.objects.create( usernameusername, passwordmake_password(password) ) return redirect(/login) return render(request, register.html)make_password 默认使用 PBKDF2 算法盐值由 Django 自己生成开发时不需要关心具体参数。这里有一个小坑如果用 Django 默认的 User 表密码字段就叫 password如果自己建 user 表写入 favorite 表的 user_id 要和 Django 认证系统的用户 id 对齐否则推荐脚本读到的行为和登录用户对不上这在联调时很容易被忽略。4.2 收藏与评论行为数据的埋点设计收藏接口是推荐系统的数据入口。用户点「收藏」时前端发一个 POST 请求后端往 favorite 表插一行。实现如下from django.views.decorators.http import require_POST from django.http import JsonResponse require_POST def add_favorite(request): user request.user scenic_id request.POST.get(scenic_id) if not user.is_authenticated: return JsonResponse({code: 401, msg: not login}) # 重复收藏直接幂等保护 if Favorite.objects.filter(user_iduser.id, scenic_idscenic_id).exists(): return JsonResponse({code: 0, msg: already exists}) Favorite.objects.create(user_iduser.id, scenic_idscenic_id) return JsonResponse({code: 0, msg: ok})require_POST 限制请求方法避免 GET 请求产生副作用。幂等保护很重要用户双击收藏按钮只会产生一条记录否则推荐算法会把同一个景点权重重复放大。评论功能在系统中的定位不只是内容展示还承担行为信号有评论行为的用户比只看不点的用户活跃度更高后续做时间衰减时可以把评论权重设得比收藏高比如评论 1.2、收藏 1.0。这个权重在 UserCF 聚合时加改动很小。推荐模块里还有一个乘车方式推荐不走协同过滤而是根据用户所在地与目的地给出最优路径。常见做法是接入地图 API 做路径规划或者内置一张城市间交通时长表用最短路算法求推荐。毕设阶段内置路线表是更可控的方案推荐结果稳定且不受外部接口配额限制。4.3 可视化大屏的数据聚合与 ECharts 渲染可视化大屏这块用的 ECharts 技术方案。大屏上的数据不是实时查询原始明细表而是先聚合再渲染。比如统计各城市景点数量用 Django ORM 写from django.db.models import Count city_stats ( Scenic.objects .values(city) .annotate(cntCount(id)) .order_by(-cnt) ) # city_stats 结构: [{city: 成都, cnt: 46}, ...]聚合结果传给模板后前端用 ECharts 的 bar 或 map 组件渲染。大屏数据可以放在缓存里做一个 5 分钟过期的 cache_key避免每次刷新都重新跑 group by。大屏上需要展示几个维度景点热度排行、用户来源分布、热门路线流量。热度排行直接取 scenic.heat来源分布从收藏表的 created_at 和用户注册地聚合路线流量用「同一用户收藏了景点 A 和 B」的共现次数来近似。这套聚合逻辑和推荐算法共用同一张 favorite 表但读的是不同的 SQL 视角。5. 推荐效果验证测试用例、兼容性清单与离线命中率评估5.1 功能与兼容性测试用例文档里有一整章系统测试实际开发里这套验证思路同样适用。兼容性测试先覆盖主流浏览器和运行设备功能测试重点放在注册、登录、推荐景点、推荐路线这四组关键路径上。测试项环境预期结果注册Chrome / Edge / Safari用户名唯一密码哈希后入库登录同上密码错误有提示成功后跳转首页景点推荐有收藏行为的账号返回景点与历史收藏相似路线推荐有收藏行为的账号路线包含用户感兴趣的景点大屏渲染Chrome 1080p图表 3 秒内完成首屏渲染设备兼容Windows / iOS / Android页面布局不溢出按钮可点击另外还要覆盖无行为用户新注册账号点进「为你推荐」时必须返回热门景点而不是空列表。空列表是 UserCF 最常见的线上事故冷启动兜底必须写进测试用例。5.2 用留一法估算推荐命中率推荐效果在文档里没有量化要让它更有说服力可以在离线环境用留一法算 precisionk对每个用户从收藏记录里随机保留一条作为测试集其余作为训练集跑 UserCF看测试集那条是否落在推荐结果里。def evaluate_hit_rate(user_items, similarity_fn, k10, topn5): hit 0 total 0 for user_id, items in user_items.items(): if len(items) 2: continue # 收藏太少无法划分 train_items set(list(items)[:-1]) test_item list(items)[-1] # 取最后一条做测试 rec_list recommend_for_user( user_id, {uid: (ti if uid ! user_id else train_items) for uid, ti in user_items.items()}, similarity_fn, kk, topntopn ) hit 1 if test_item in rec_list else 0 total 1 return hit / total这里用最后一条收藏做测试而不是随机是为了让实验可复现。hit rate 在这个数据集上基本只有两到四成因为用户收藏记录稀疏这是正常值。拿热门推荐做 baseline 对比如果 UserCF 的命中率拼不过热门推荐先调 k再把城市先验加进相似度再看命中率变化。把 k 从 10 改成 20 再跑一次观察命中率是否提升就能确认推荐结果是不是因为相似用户太少而收得过窄。本文还有配套的精品资源点击获取