旅游景点智能推荐系统毕设:Django协同过滤与可视化大屏全解析
做毕设辅导这几年旅游景点智能推荐系统算是我被问得最频繁的题目之一。很多同学从资源站下载了那套常见的“Django 协同过滤 ECharts大屏”源码结果打开项目目录就发怵文件夹密密麻麻算法和业务代码混在一起数据库脚本也不知道先跑哪条。这篇文章不做代码逐行翻译而是把这类毕设源码从数据模型、协同过滤算法、API 设计到可视化大屏的整体链路拆开讲一遍顺便把最容易翻车的几个位置标出来。适合计算机专业准备答辩的本专科生也适合第一次用 Django 写完整项目的初学者先收藏等到动手搭环境时对着操作。1. 为什么旅游景点智能推荐是“稳赚不赔”的毕设选题1.1 推荐系统题目的品类红利算法、数据、展示全都有毕设题目能不能让导师点头其实就看三件事有没有算法含量、有没有数据支撑、有没有可视化或应用场景。纯管理系统难拿高分纯算法研究又容易做成一堆公式推导对多数本科生来说风险太大。旅游景点智能推荐刚好卡在中间既有协同过滤这种能讲清楚的算法又有景点、用户、评分这类易理解的数据做出来还自带一个颜值很高的可视化页面。这个题目的另一个好处是扩展空间大。导师如果要求“要有大数据的感觉”你可以加用户行为日志、热门榜统计、城市热度分布如果要求“要有新意”你可以在协同过滤之外接一个行程规划模块甚至用大模型 API 做景区问答。真出成果以后明显比单纯的学生信息管理系统更适合用来写软著和论文。1.2 标题里到底藏了多少层技术栈拿到一个毕设题目先别急着写代码把技术栈拆开才能在答辩时讲清楚每个词背后的工作。技术点在项目里负责什么复杂度Python主要开发语言算法和脚本都用它低Django后端框架处理页面、路由、ORM、接口中协同过滤推荐核心基于用户历史评分找相似景点中可视化用图表展示统计数据一般是 ECharts 大屏中数据分析用统计指标分析景点、评分、用户行为低大数据思维不要求真上 Hadoop指的是行为日志和批量离线处理思路低大模型扩展可选的语义问答、行程摘要功能高这个表格就是你的“论文三线表底稿”。答辩时导师问“你用了哪些技术”你顺着这个思路讲基本不会乱。1.3 别把差异化押在“再做一遍”上旅游推荐系统的开源源码很多但大部分只是拼凑前端一个模板、后端一个 crud、算法就是一个 sklearn KNN。如果你想拿高分至少要在一两个点上有自己的想法。我的建议是关注三个差异方向。第一多维度推荐不只是给用户推“整体相似”的景点而是结合城市、分类、门票价格做筛选例如用户看杭州的景点时优先推荐杭州同城景区。第二行为日志驱动推荐除了用户主动打的分数把浏览、收藏、搜索都记下来冷启动问题会缓解很多。第三增加结果解释告诉用户“因为你看过西湖所以给你推荐千岛湖”这项功能在答辩时非常加分。2. 先搭数据底座Django 模型设计与数据导入实操2.1 项目初始化和目录规划拿到源码第一件事不是读算法而是先把项目跑起来。我先说最标准的目录结构避免你在别人的源码里迷路。# 创建虚拟环境并激活 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate # 安装依赖至少要有 django、djangorestframework、pandas、numpy pip install django djangorestframework pandas numpy # 创建项目和 app django-admin startproject travel_project python manage.py startapp recommend项目结构大致如下travel_project/ manage.py travel_project/ settings.py urls.py recommend/ models.py views.py serializers.py recommender.py management/ commands/ import_spots.py templates/ static/ data/ spots.csv把算法单独拆成recommender.py而不是写在views.py里这是大部分好口碑源码的共同习惯。后续调算法、跑离线计算都会方便很多。2.2 核心模型三件套景点、评分、用户行为Django 自带了用户表我一般不在用户模型上做太多改动除非你想加头像、昵称。真正需要手动建的是三张表景点表、评分表、浏览行为表。from django.db import models from django.contrib.auth.models import User class Category(models.Model): name models.CharField(max_length50, uniqueTrue, verbose_name分类) def __str__(self): return self.name class ScenicSpot(models.Model): name models.CharField(max_length100, verbose_name景点名称) city models.CharField(max_length50, db_indexTrue, verbose_name城市) category models.ForeignKey( Category, on_deletemodels.SET_NULL, nullTrue, verbose_name分类 ) description models.TextField(blankTrue, verbose_name景点介绍) price models.FloatField(default0, verbose_name门票价格) longitude models.FloatField(verbose_name经度) latitude models.FloatField(verbose_name纬度) cover_url models.URLField(blankTrue, verbose_name封面图) rating_avg models.FloatField(default0, verbose_name平均评分) rating_num models.IntegerField(default0, verbose_name评分数) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 景点 verbose_name_plural 景点 def __str__(self): return self.name class SpotRating(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name用户) spot models.ForeignKey( ScenicSpot, on_deletemodels.CASCADE, related_nameratings, verbose_name景点 ) score models.PositiveSmallIntegerField(choices[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]) comment models.TextField(blankTrue, verbose_name评论) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together (user, spot) verbose_name 景点评分 class ViewLog(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, nullTrue, verbose_name用户) spot models.ForeignKey(ScenicSpot, on_deletemodels.CASCADE, verbose_name景点) action models.CharField(max_length20, defaultview, verbose_name行为类型) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 浏览日志为什么评分要单独一张表而不直接把平均分放在景点表因为推荐算法需要用户的原始评分记录而不是只读平均值。平均分是给列表页展示用的原始评分才是协同过滤的养料。顺带一提unique_together保证同一个用户对同一个景点只能评一次分后面用update_or_create更新时会很舒服。2.3 数据导入一份能直接跑通的 CSV 管理命令最省事的方案是用 Django 管理命令导入景点数据比在shell里一行行敲要规范得多。# recommend/management/commands/import_spots.py import csv from django.core.management.base import BaseCommand from recommend.models import ScenicSpot, Category class Command(BaseCommand): help 从 CSV 导入景点数据 def add_arguments(self, parser): parser.add_argument(csv_path, typestr) def handle(self, *args, **options): with open(options[csv_path], encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: category_name row.get(category, 未分类) category, _ Category.objects.get_or_create(namecategory_name) ScenicSpot.objects.update_or_create( namerow[name], defaults{ city: row[city], category: category, description: row.get(description, ), price: float(row.get(price, 0) or 0), longitude: float(row.get(longitude, 0)), latitude: float(row.get(latitude, 0)), cover_url: row.get(cover_url, ), }, ) self.stdout.write(self.style.SUCCESS(景点数据导入完成))CSV 的最小结构可以是这样name,city,category,description,price,longitude,latitude,cover_url 西湖,杭州,自然风光,免费世界遗产,0,120.15,30.24,https://... 故宫,北京,博物馆,明清皇宫,60,116.40,39.92,https://...执行命令python manage.py makemigrations python manage.py migrate python manage.py import_spots data/spots.csv2.4 数据清洗里最容易忽略的三个坑第一同名景点要去重。不同信息源里的“西湖风景区”和“西湖”可能是同一个景点我建议把“名称 城市”一起作为唯一键处理。第二经纬度不要只存字符串转成 Float 方便后续做地图热力图。第三评分缺失问题。模拟数据可以随机生成但生成完要看分布别让所有用户都打 5 分否则推荐结果会严重偏向热门景点算法效果根本展示不出来。3. 协同过滤算法的核心原理与手写实现3.1 为什么毕设首选协同过滤而不是深度学习推荐领域有很多种做法基于内容的推荐、协同过滤、深度学习模型、大模型提示词推荐。毕设最怕的就是“算法理论写了两大页代码里一行 sklearn 也没调”。深度学习需要大量训练数据旅游评分场景天然稀疏强行上模型很容易得到难看的结果。协同过滤的好处是直观、可解释、代码量不大。它的基本假设是过去喜欢相似景点的人未来也会喜欢相似的景点。针对景点推荐我通常选基于物品的协同过滤因为景点数量相对稳定、更新慢而且算法结果更容易解释“因为你喜欢西湖所以推荐同类型的千岛湖”这句话放到答辩 PPT 里就是亮点。3.2 基于物品的协同过滤到底怎么算先用一句话概括算出景点之间的相似度再用用户历史喜欢的景点去加权投票产生推荐列表。例如有三个用户用户西湖千岛湖故宫用户A543用户B450用户C035要计算“西湖”和“千岛湖”的相似度可以看所有用户对这两个景点的评分向量。西湖的向量是 (5,4,0)千岛湖是 (4,5,3)。余弦相似度就是两个向量夹角的余弦值值越大表示评分模式越像。这类手算例子写进论文非常合适。计算完所有景点两两相似度后对某个用户把他评分过的景点作为“种子”去找与这些种子最相似的、且用户没看过的景点按“相似度 × 用户评分”加权求和排序得到最终 Top N。3.3 一份能跑起来的 ItemCF 代码不一定要用 sklearnPandas Numpy 足够。下面这段代码是这类源码里最常见的核心算法模块可以放进recommender.py。import pandas as pd import numpy as np class ItemCF: def __init__(self, ratings: pd.DataFrame): self.ratings ratings # 必须包含 user_id, spot_id, score self.user_item None self.item_sim None def build_matrix(self): self.user_item self.ratings.pivot_table( indexuser_id, columnsspot_id, valuesscore ).fillna(0) def calc_cosine_sim(self): item_user self.user_item.T.values norm np.linalg.norm(item_user, axis1, keepdimsTrue) item_user_norm item_user / (norm 1e-8) sim np.dot(item_user_norm, item_user_norm.T) self.item_sim pd.DataFrame( sim, indexself.user_item.columns, columnsself.user_item.columns, ) def recommend(self, user_id, top_n10): if user_id not in self.user_item.index: return [] scores {} user_row self.user_item.loc[user_id] rated set(user_row[user_row 0].index) for spot_id in rated: sim_series self.item_sim[spot_id].drop(labelsrated, errorsignore) sim_series sim_series[sim_series 0] for cand, sim in sim_series.items(): scores[cand] scores.get(cand, 0) sim * user_row[spot_id] sorted_scores sorted(scores.items(), keylambda x: x[1], reverseTrue) return [sid for sid, _ in sorted_scores[:top_n]]需要注意三点。第一drop(labelsrated)是为了把用户已经去过的景点排除掉不然推荐系统老推你看过的东西导师体验不好。第二1e-8是为了防止分母为 0计算稳定性必须考虑。第三矩阵很小的时候这段代码很快但真实环境的评分数据可能有几万条建议提前想到缓存。3.4 离线计算和缓存是毕设的“隐藏加分项”协同过滤没必要每次请求都重新算一遍。景点之间的相似度是相对稳定的可以每天晚上离线跑一次算完把结果写进缓存或数据库。页面展示时直接读推荐结果响应时间能降到毫秒级。答辩的时候说一句“我采用离线计算、在线读取的策略”比手忙脚乱现场跑算法体面得多。最简单的做法是算完 ItemCF 后把每个用户的推荐列表转成 JSON 存起来import json with open(rec_cache.json, w, encodingutf-8) as f: json.dump({user_1: [101, 102, 103]}, f, ensure_asciiFalse)等用户登录后先从缓存查查不到再用 ItemCF 实时兜底。4. API 层用 DRF 把推荐能力开放给前端4.1 为什么推荐系统要单独做 RESTfull API推荐能力不应该只用在 Web 页面里。如果你以后想扩展成微信小程序、App或者跑批任务统一走接口是最省事的。Django REST FrameworkDRF在毕设源码里几乎是标配理由很简单序列化方便、权限控制直观、接口调试页面自带。安装 DRF 后在settings.py里注册INSTALLED_APPS [ ... rest_framework, recommend, ]4.2 序列化器与景点列表接口先写序列化器把景点模型转成 JSON。from rest_framework import serializers from recommend.models import ScenicSpot class ScenicSpotSerializer(serializers.ModelSerializer): category_name serializers.CharField(sourcecategory.name, read_onlyTrue) class Meta: model ScenicSpot fields [ id, name, city, category_name, description, price, cover_url, rating_avg, rating_num, ]在views.py里写景点列表接口from rest_framework.decorators import api_view from rest_framework.response import Response from django.db.models import Count from recommend.models import ScenicSpot from recommend.serializers import ScenicSpotSerializer api_view([GET]) def spot_list_api(request): city request.GET.get(city, ) category request.GET.get(category, ) spots ScenicSpot.objects.annotate( rating_countCount(ratings) ).order_by(-rating_count, -rating_avg) if city: spots spots.filter(citycity) if category: spots spots.filter(category__namecategory) data ScenicSpotSerializer(spots[:100], manyTrue).data return Response({list: data})接口设计上给city和category参数可以让后面的推荐列表支持筛选。这一层也是很多同学忽略的“细节工作”但导师很吃这一套因为它说明你不是只做了一个死板的推荐接口。4.3 推荐接口与评分提交接口个性化推荐是核心接口。逻辑一般是这样如果用户没有评分行为返回热门榜如果有就读取 ItemCF 推荐结果。from django.core.cache import cache from recommend.recommender import ItemCF def get_recommend_ids(user_id, top_n10): cache_key frec_user_{user_id} cached cache.get(cache_key) if cached: return cached ratings pd.DataFrame(list(SpotRating.objects.values(user_id, spot_id, score))) cf ItemCF(ratings) cf.build_matrix() cf.calc_cosine_sim() rec_ids cf.recommend(user_id, top_ntop_n) cache.set(cache_key, rec_ids, 60 * 60) return rec_ids评分提交接口也不复杂关键在于用update_or_create同一个用户重复提交时直接更新。from django.db.models import Avg, Count from recommend.models import SpotRating, ScenicSpot api_view([POST]) def add_rating_api(request, spot_id): if not request.user.is_authenticated: return Response({detail: 请先登录}, status401) score request.data.get(score) if not score or int(score) not in [1, 2, 3, 4, 5]: return Response({detail: 评分必须在1-5之间}, status400) spot ScenicSpot.objects.get(idspot_id) SpotRating.objects.update_or_create( userrequest.user, spotspot, defaults{score: int(score)}, ) agg SpotRating.objects.filter(spotspot).aggregate( avgAvg(score), numCount(id) ) spot.rating_avg round(agg[avg] or 0, 2) spot.rating_num agg[num] or 0 spot.save() return Response({status: ok, rating_avg: spot.rating_avg})4.4 冷启动兜底没有行为数据时推荐什么新用户没有任何评分时ItemCF 会返回空列表。这种情况一定要兜底不然前端加载就是空白。我的兜底顺序是热门榜 - 城市随机 - 默认榜单。热门榜直接用rating_num排序代码是现成的。答辩时被问到“新用户来了怎么办”你就说“采用热门榜冷启动策略”简单有效。5. 数据分析与可视化大屏让导师看到你的“工作量”5.1 可视化不是堆图表先定分析指标很多人以为可视化就是多放几个 ECharts 图表结果放了一堆饼图每个饼图之间没有逻辑关系。这里我先列推荐系统最该分析的几个指标热门景点 TOP10看整体流量集中度城市景点数量分布看资源结构用户评分分布看系统评分是否健康分类占比看产品库覆盖情况用户活跃时段或行为统计如果有行为日志的话。这些指标全部围绕同一个主题这套推荐系统覆盖了哪些景点、用户反馈如何、推荐结果有没有偏离。把图表连成一条故事线比堆数量有用得多。5.2 用 Django ORM 聚合查询准备数据源分析接口我通常直接返回 JSON前端再渲染图表。比如热门景点接口from django.db.models import Avg, Count from django.http import JsonResponse def stats_api(request): top_spots ScenicSpot.objects.annotate( avg_scoreAvg(ratings__score) ).order_by(-rating_num, -avg_score)[:10] city_stats ScenicSpot.objects.values(city).annotate( cntCount(id) ).order_by(-cnt)[:10] score_dist SpotRating.objects.values(score).annotate( cntCount(id) ).order_by(score) return JsonResponse({ top_spots: list(top_spots.values(name, rating_num, avg_score)), city_stats: list(city_stats), score_dist: list(score_dist), })这里用到了annotate它能对一个分类字段做分组统计。第一次接触 Django 的同学可能会在values和annotate的顺序上踩坑记住一个口诀先分组再统计后排序。5.3 在 Django 模板里接入 EChartsECharts 用 CDN 引入最简单接数据用 Fetch 拉接口不需要前端框架。!DOCTYPE html html langzh head meta charsetUTF-8 title旅游数据分析大屏/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script /head body div idhot-spot stylewidth: 800px; height: 400px;/div script fetch(/api/stats/) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(hot-spot)); chart.setOption({ title: { text: 热门景点 TOP10 }, tooltip: {}, xAxis: { type: category, axisLabel: { interval: 0, rotate: 30 } }, yAxis: { type: value }, series: [{ type: bar, data: data.top_spots.map(item item.rating_num) }] }); }); /script /body /html图表加载失败的常见原因是接口返回的字段名和前端对不上。建议在浏览器先访问一次/api/stats/确认 JSON 键名再对着键名写 JS。5.4 图表配合讲解的“万能四步”大屏不是给导师看的是给答辩讲解用的。我的经验是按照“现象、原因、影响、改进”四步来讲每一张图。比如热门景点 TOP10 里杭州占了四席可以说“数据说明华东自然风光类景点热度高因此推荐算法会在该地域投入更高权重未来可以增加华东景点的供给”这样话术一出来答辩就不像在念代码而是在做数据分析。6. 答辩与部署避坑指南从源码跑通到演示不翻车6.1 评分数据稀疏怎么办旅游景点评分数据天然稀疏一个用户可能只踩过十几个景点但景点库有几百上千条。矩阵里大部分是 0相似度计算很容易失真。我的处理经验是把“显式评分”和“隐式行为”一起用。用户浏览过、收藏过景点也算正反馈可以统一转成 4 分或 5 分的软评分。数据仍然不够时用热门榜兜底。另外可以限制只计算“最近 N 个行为”避免几年前的旧行为把推荐结果带偏。6.2 推荐计算太慢怎么办如果你的模型执行calc_cosine_sim()卡了几分钟不要慌多半是矩阵太大了。毕设数据量几千条直接算没问题更多就要控制参与计算的景点数比如只取评分最多的前 200 个景点算相似度其余的走热门榜。实时接口一定要加缓存。最简单的用 Django 内置cachefrom django.core.cache import cache cache.set(item_sim_matrix, sim_df, timeout60 * 60 * 12)第二次请求直接读缓存演示现场手感会非常顺滑。这个优化在文档里写一句话性价比极高。6.3 现场演示最容易翻车的三个细节第一个坑是数据库没迁移。很多源码部署到新环境后migrate不执行就想去创建数据直接报错。第二个坑是静态文件和图片缺失建议把所有图片换成网络 URL或者提前放到本地static目录。第三个坑是端口冲突。演示前用python manage.py runserver 0.0.0.0:8000启动并且提前打开浏览器测试两遍确认登录、推荐、评分这三个主链路都正常。还有一个隐藏坑是admin后台没有创建超级用户。演示完普通用户流程后导师往往想进后台看看数据表你现场才createsuperuser就尴尬了。提前建好并在 PPT 里放两张后台截图。6.4 想冲高分的扩展方向大模型对话式行程助手标题里提到“大模型”不少人会担心是不是非要部署一个本地大模型。其实完全不用。比较稳妥的做法是保留 ItemCF 作为核心推荐另外做一个“行程问答”模块调用成熟的自然语言处理接口让用户输入“我想去杭州玩三天”系统返回景点组合和路线建议。这个模块可以作为选做亮点写进论文最后一章也能在答辩时演示。需要注意两点接口调用要加缓存和错误兜底不能让网络超时拖垮演示成本要可控限定每个用户每天调用次数。答辩时用一句“大模型负责生成推荐系统负责个性化”概括这个组合既能回应热点又不会显得你在追着概念跑。最后说点掏心窝的话。我见过太多同学拿到源码后第一时间去读算法文件结果卡在矩阵计算里出不来。正确顺序是先把数据库导入跑通然后在 Django 后台随便创建几个用户和评分再调推荐接口最后才去动算法细节。真实答辩里最影响成绩的往往不是算法精度而是演示流程顺不顺、图表加载快不快、导师问“相似度怎么算”时你能不能答得上来。建议先把这篇文章收藏等环境搭起来后再对着操作一遍踩过的坑大概率都能避开。