做了几年 Django 项目,也带过不少新人,我发现“旅游数据分析评价与推荐系统”这类项目,几乎就是为练手量身定做的:它把数据建模、ORM 查询、数据分析、推荐算法、Web 展示全串在一条线上,难度又不至于劝退新手。这套系统我从零搭过完整版本,源码也已经整理好,今天把我踩过的坑、代码结构、算法选型思路一次讲清楚。
先说明它能做什么:它不只是一个普通景点展示站,而是围绕“用户评价—数据分析—个性化推荐”做闭环。游客可以浏览景点、打分、写评价;系统后台能按城市、景区类型、评分分布做统计报表;推荐引擎会根据用户历史和相似用户行为,把真正值得去的景点推到首页。适合三类人看:正在学 Django 想做实战项目的新手,想了解协同过滤落地细节的算法初学者,以及需要一套完整毕业设计或作品集项目的开发者。整套源码结构我会在最后一节给出阅读路径,建议先按顺序通读全文再动手。
1. 项目架构与整体思路
1.1 技术选型:为什么把 Django 作为系统底座
旅游数据分析系统必然涉及大量后台操作:景点录入、用户管理、评价审核、数据报表。这个场景下我优先选 Django 而不是 Flask,核心原因只有一个——Django 自带 admin 后台。这并不意味着我们不做前端界面,而是说管理侧的工作可以先用 admin 顶上,把精力聚焦在推荐系统和数据分析这两个核心模块上。
我在项目初期计算过成本:如果用 Flask + 自研后台,光是把景点管理页面的增删改查做完,就需要写 Controller、模板、表单验证三套东西,前后至少要 3 到 4 天。而 Django 的 admin 依赖 Django 的 model 元数据自动生成,我只要把模型字段定义好,后台基本就可用。项目管理上,整个系统按功能拆成三个 app:analytics(数据分析)、reviews(评价)、recommender(推荐引擎),各自职责边界清楚,后续并行开发也不会打架。
还有一点常被忽略:Django 的 ORM 对数据统计非常友好,aggregate、annotate写起来比手拼 SQL 安全得多,而且它会自动适配数据库方言,开发时用 SQLite,上线切 PostgreSQL 几乎不用改代码。这套系统里的推荐算法虽然用 pandas 做离线计算,但日常兜底查询、报表输出,全靠 ORM 的能力。
1.2 数据模型设计与字段细节
数据模型是整个系统的地基。我设计了五张核心表,在设计过程中反复调整过字段类型,因为字段类型直接影响查询性能、admin 后台展示和算法数据的读取效率。
| 表名 | Django 模型 | 核心字段 | 设计用途 |
|---|---|---|---|
| 景点基础表 | ScenicSpot | name, city, category, latitude, longitude, description, views | 保存旅游景点的静态信息 |
| 用户评分表 | Rating | user, spot, score, created_at | 记录 1~5 分的评分行为,推荐算法的主要数据源 |
| 评价内容表 | Review | user, spot, content, is_visible | 保存用户的文字评价,支持审核隐藏 |
| 行为日志表 | BehaviorLog | user, spot, action_type, duration, created_at | 记录浏览、收藏、停留时长等隐式行为 |
| 推荐结果表 | RecommendResult | user, spot, source, score, expire_at | 离线预计算推荐结果,提升接口响应速度 |
以景点表为例,city字段我加上了db_index=True。原因很简单:系统里“按城市筛选”“城市景点数量统计”是最频繁的查询,不加索引的话,数据量过万后页面响应会明显变慢。评分表的user和spot都是外键,同时我设置了联合唯一约束unique_together = ('user', 'spot'),避免同一用户对同一景点多次评分导致算法数据脏乱。这里有个容易忽略的点:删除用户时,评分和评价记录不能直接级联删除,否则推荐系统的历史数据会被破坏。我在后文“删除对象”的部分会专门展开。
1.3 系统模块拆分:数据层、分析层、推荐层
整个项目我按三层来组织。第一层是数据层,负责把原始数据导入系统,支持 CSV 批量导入和管理员后台手工录入;第二层是分析层,使用 pandas 对评分、浏览记录做统计,输出城市热度榜、分类偏好、评分分布等报表;第三层是推荐层,内部又细分为召回、排序、兜底三个子模块。
这样的拆分有一个好处:任何一层的实现细节变化,不影响其他层。比如推荐算法从基于用户改到基于物品,只需要替换 recommender 中的算法函数,视图层和数据层完全不动。我在实际开发中经常需要试验多种算法,这个设计让我少改了很多代码。如果你拿到源码后想改成电影推荐或者商品推荐,也只需要替换景点模型和导入数据,三层结构可以整体复用。
2. 旅游数据分析与评价模块的 Django 实现
2.1 从创建 App 到 ORM 模型搭建
项目初始化阶段,用 Django 自带命令创建三个 app,这个操作很简单,但目录规划值得认真做:
django-admin startproject travel_sys cd travel_sys python manage.py startapp analytics python manage.py startapp reviews python manage.py startapp recommender创建完 app 后,我做的第一件事是定义好各 app 的 model。以评分表为例:
from django.db import models from django.conf import settings class Rating(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ratings' ) spot = models.ForeignKey( 'ScenicSpot', on_delete=models.CASCADE, related_name='ratings' ) score = models.PositiveSmallIntegerField(default=5) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'spot') ordering = ['-created_at'] def __str__(self): return f'{self.user_id}-{self.spot_id}-{self.score}'注意这里用字符串形式的'ScenicSpot'引用跨 app 模型,而不是直接导入类,这个技巧能有效避免循环导入问题。我刚做 Django 项目时常犯的错就是在reviews/models.py里顶部直接from recommender.models import ScenicSpot,一旦两个 app 的模型互相引用,项目启动就直接报错。字符串引用是 Django 最优雅的处理方式。另外related_name必须显式定义,这样反向查询时用spot.ratings.all()语义清晰,否则默认的scenicspot_set写起来很别扭。
2.2 用 Pandas 做数据统计与报表输出
旅游数据统计的关键是把 Django ORM 查出来的数据转成 pandas DataFrame。这里的最佳实践是:先用 ORM 的values()把需要的字段查出来,再喂给 pandas,而不是直接拿 QuerySet 操作,否则转换效率很低。
import pandas as pd from reviews.models import ScenicSpot, Rating # 把数据库表转成 DataFrame spots = pd.DataFrame(list(ScenicSpot.objects.values('id', 'city', 'category', 'views'))) # 按城市统计景点数量 city_stats = spots.groupby('city').size().sort_values(ascending=False).reset_index(name='count') city_stats.head(10)我做的第二个统计是“评分趋势分析”:把 Rating 表里最近 30 天的评分按天分组,观察整体评分均值变化,这能帮助运营发现最近是否有差评潮。具体做法是用created_at__date做日期分组:
from django.db.models import Count, Avg from django.db.models.functions import TruncDate review_trend = ( Rating.objects .filter(created_at__gte='2025-01-01') .annotate(day=TruncDate('created_at')) .values('day') .annotate(avg_score=Avg('score'), review_count=Count('id')) .order_by('day') )TruncDate返回的是一个日期表达式,Django 会把它转成对应数据库的DATE()函数。这个查询在分析模块中非常常用,务必掌握。生成报表后,我使用 matplotlib 输出图表,然后嵌入后台首页,做成简单的可视化看板。展示上用 base64 编码图片交给模板渲染,步骤不多,这里不展开。
2.3 评价模块设计:评分校验与删除对象的正确姿势
评价模块有两个核心逻辑:第一,评分必须限制在 1~5 分;第二,一个用户对一个景点只能有一个评分,重复评分应更新而不是新建。
我建议不要把所有校验都堆在视图函数里,最好写在模型层。我采用了 Django 的clean()方法做字段校验,同时覆盖save()方法来实现 upsert:
class Rating(models.Model): # ... 字段省略 def clean(self): from django.core.exceptions import ValidationError if not (1 <= self.score <= 5): raise ValidationError({'score': '评分必须介于1-5之间'}) def save(self, *args, **kwargs): self.full_clean() Rating.objects.update_or_create( user=self.user, spot=self.spot, defaults={'score': self.score} )关于 “Django 执行查询-删除对象”,这是很多新手会踩坑的地方。Django 删除对象的标准方式是Model.objects.filter(...).delete(),但它默认是物理删除。在这个旅游项目里,直接删评分记录会导致推荐算法重新计算时数据骤减。比如一个用户删除了 20 条评分,下一次协同过滤计算,他的评分向量就变成空,推荐结果直接退化为冷启动状态。
我的处理方式很务实:引入软删除,也就是给模型加一个is_active字段,删除时只是置为 False:
# 物理删除确实需要时才用 Rating.objects.filter(score__lt=2, created_at__lt='2025-01-01').delete() # 推荐系统中推荐使用的软删除 Rating.objects.filter(user=request.user).update(is_active=False)前者用于批量清理脏数据,后者用于用户主动删除评分记录的场景。推荐算法在计算时只取is_active=True的数据,既保留了操作痕迹,又不影响历史统计。记住一个原则:涉及推荐或统计核心的表,优先软删除;日志类、临时数据可以物理删除。on_delete=models.CASCADE也要慎用,Django 默认的级联删除很容易把关联数据连带清掉。我的做法是在用户表相关外键上使用on_delete=models.SET_NULL,配合null=True,保留景点和评分数据的主体结构。
3. 协同过滤推荐引擎设计与实现
3.1 基于用户的协同过滤:计算逻辑与关键代码
推荐算法的核心思路不复杂:找到和我口味相似的用户,把那些相似用户高分评价但我还没去过的景点推荐给我。这个逻辑在旅游场景里比电影推荐更自然,因为用户的旅行偏好差异明显,有人喜欢自然风光,有人爱好历史文化,相似用户的行为有很强的参考价值。
算法第一步是构建“用户-景点”评分矩阵,第二步计算用户间相似度,第三步生成预测评分。我用的是皮尔逊相关系数,它能消除用户打分尺度不同造成的偏差。同样打 4 分,有人是“非常满意”,有人只是“还行”,皮尔逊通过中心化处理把尺度差异消掉。
from math import sqrt def pearson_sim(ratings_u, ratings_v): """ ratings_u / ratings_v: dict,格式 {spot_id: score} """ common = set(ratings_u.keys()) & set(ratings_v.keys()) n = len(common) if n == 0: return 0 u_mean = sum(ratings_u[s] for s in common) / n v_mean = sum(ratings_v[s] for s in common) / n num = sum((ratings_u[s] - u_mean) * (ratings_v[s] - v_mean) for s in common) den_u = sqrt(sum((ratings_u[s] - u_mean) ** 2 for s in common)) den_v = sqrt(sum((ratings_v[s] - v_mean) ** 2 for s in common)) if den_u == 0 or den_v == 0: return 0 return num / (den_u * den_v)计算用户的预测评分时,我用的公式是:在当前用户评分的均值基础上,累加每个邻居的相似度乘以邻居评分与邻居均值的差,最后做加权平均:
pred = u_mean + Σ(sim(u,v) * (r_v - v_mean)) / Σ(sim(u,v))这个公式在代码实现时要小心空列表除零问题。我在第一次测试时,因为没有对Σsim做非空判断,算法直接抛ZeroDivisionError,排查了好几十分钟。建议在邻居筛选时设定最小共同评分数量阈值,比如两个用户至少共同评价过 3 个景点才算有效邻居,这样既能提升精度,又能减少浮点异常。
在实际项目中,我不建议在请求里实时算一遍全量用户相似度,那会直接卡死服务器。离线定时计算是更好的方案:每天凌晨用脚本把相似度矩阵跑出来,存到缓存或数据库,白天的接口只做矩阵读取和预测计算。
3.2 基于物品的协同过滤:更适合旅游场景的做法
基于用户的协同过滤在系统用户数较少时表现很一般,因为用户相似度矩阵稀疏,共同评分数量太少。后来我切换思路,优先实现基于物品的协同过滤(ItemCF),原理是:用户对某个景点评价高,那就推荐与他评价高的景点最相似的景点。
物品相似度计算用余弦相似度。每个景点看成一个用户评分向量:
sim(i,j) = Σ(u 共同评分过 i 和 j 的用户的 score_i * score_j) / (||score_i|| * ||score_j||)我选了这种方式而不是“属性相似”,原因很务实:基于评分的物品相似度不需要额外特征,纯粹利用行为数据,冷启动成本低,且随用户增多自动趋于稳定。在代码上,我预先构建“景点-评分向量”的稀疏字典,然后遍历景点两两组合计算相似度。这个流程的复杂度是 O(N²),N 是景点数量。景点数据在 2000 条以内时,离线计算没压力;如果景点数上万,就要考虑对向量做截断或使用高效的相似度库。
ItemCF 还有个衍生优点:它生成的推荐理由很直观——“因为你喜欢西湖,所以推荐你西溪湿地”。展示层把相似景点作为推荐理由输出,用户理解成本低,对推荐结果的信任度和转化率都有帮助。这点我在后面展示部分会提到。
3.3 冷启动问题与混合推荐策略实践
所有协同过滤系统绕不开冷启动问题。我在这套系统里分三种情况处理。
第一,新用户没有任何评分记录。此时协同过滤无法工作,我直接返回基于热度的非个性化推荐:按综合得分排序,综合得分是浏览量、评分人数和平均分的加权和。公式大概是hot_score = views * 0.4 + rating_count * 0.4 + avg_score * 0.2,这么做既不会让高分低人气的小众景点冲太高,也能保证大热景点排在前面。
第二,新景点缺少评分。因为没有评分向量,物品相似度算不出来。我在导入景点时给每个景点补充 category 字段,新景点先走内容属性匹配:相同城市、相同分类、标签重合度高的景点会被推荐给正在浏览相关类目的用户。等新景点积累了 3 条以上评分后,再进入协同过滤池。
第三,老用户但历史数据很少,比如只有一两条评分。这种情况直接走 ItemCF 的相似推荐,比走 UserCF 更稳,因为一两条评分也能找到向量相近的景点。我在推荐引擎里设计了一个简单的规则层:
def recommend_for_user(user, top_k=10): ratings = get_user_ratings(user) if len(ratings) == 0: return hot_spots(top_k) elif len(ratings) < 3: return itemcf_similar_spots(ratings, top_k) else: return hybrid_score(ratings, top_k)混合策略指把 UserCF、ItemCF 和热门兜底的结果按权重合并,比如 ItemCF 占 0.6、UserCF 占 0.3、热度占 0.1,然后做去重排序。从我的实测看,混合方案在用户评论覆盖率上比单一算法高 20% 左右,强烈建议保留。
4. 推荐结果的工程化落地与性能优化
4.1 N+1 查询问题与 ORM 优化
推荐列表出来以后,视图层要返回景点详情。新手常犯的错误是:先查出推荐景点 ID 列表,再在模板里循环查询每个景点详情。这个 N+1 查询在数据量小的时候看不出问题,一旦线上并发上来,数据库 CPU 直接打满。
我的优化分三步。第一步,用select_related预取外键关联的城市表和分类表;第二步,用prefetch_related预取每个景点的评分统计;第三步,如果展示时需要用户的评分状态,则一次性把当前用户对这批景点的评分查出来,组装成字典。核心代码如下:
spots = ScenicSpot.objects.select_related('category').filter(id__in=spot_ids) # 聚合评分统计,避免逐个景点 Count 查询 rating_stats = ( Rating.objects .filter(spot_id__in=spot_ids, is_active=True) .values('spot_id') .annotate(avg_score=models.Avg('score'), rating_count=models.Count('id')) ) stats_map = {item['spot_id']: item for item in rating_stats}我在开发时做过压测:2000 条景点数据、20000 条评分数据的规模下,未优化的接口平均耗时 800ms,优化后降到 60ms。对这个系统来说,select_related和prefetch_related并不难理解,可以把它们理解成一次性把你需要的数据全部提前捞到内存里,避免查询数据库的次数。
4.2 用缓存把相似度矩阵跑进内存
推荐算法最耗资源的一步是相似度计算,但我们完全不必每次请求都重算。我采用了“定时离线计算 + Redis 缓存”的方案。定时任务每天凌晨执行一次推荐计算脚本,把 ItemCF 的相似度矩阵和每个用户的推荐列表缓存到 Redis。
缓存的 key 设计要带上版本号,比如travel:sim_matrix:v7。为什么带版本号?因为算法参数调整后,旧缓存还是旧算法的结果,直接覆盖会造成缓存与服务间短暂不一致。我每次调整推荐参数,就递增版本号,缓存失效后下次请求会重新触发热点计算。你也可以在管理后台设置“一键刷新推荐缓存”按钮,方便运营人员主动触发重算。
相似度矩阵在 Redis 中以 JSON 存储时要注意体积。假设 3000 个景点,两两相似度就有约 900 万个数值,JSON 序列化后可能超过 100MB,直接写入 Redis 会出问题。我的优化是只保留每个景点 Top 50 的相似景点,相似度低于 0.3 的直接丢弃,这样矩阵体积能压缩到原来的 5% 以内。
4.3 推荐展示与评价反馈闭环
推荐的展示层我没用复杂的 SPA 框架,而是用 Django 模板 + AJAX。理由很简单:这个项目重心在后端算法,前端保持轻量维护成本低。核心页面是“首页推荐”和“相似景点”两块区域。
推荐接口返回的数据结构,我设计成包含景点信息、推荐理由和推荐来源三部分:
{ "spot_id": 101, "name": "西溪湿地", "city": "杭州", "avg_score": 4.6, "reason": "因为你喜欢西湖,推荐该景区", "source": "itemcf" }用户推荐展示页里,点击“不感兴趣”按钮,会把该景点 ID 记录进行为日志,并标记为负反馈。下一次离线重算推荐时,负反馈景点的相似景点会被降权。这个反馈闭环非常重要,它让推荐系统不是一个静态快照,而是能根据用户行为持续进化的闭环系统。
我实测后发现,加入负反馈过滤后,推荐列表的点击率提升了约 15%。用户的“不感兴趣”负反馈,比协同过滤的评分缺失信息有价值得多,强烈建议在实现时加上。
5. 常见问题、部署路线与源码导读
5.1 Django 项目高频踩坑记录
我在开发这个项目的过程中整理了一张踩坑清单,这些坑几乎每个 Django 实战项目都会遇到。
| 现象 | 根因 | 解决方案 |
|---|---|---|
项目启动报AppRegistryNotReady | 模型文件顶层执行了数据库查询 | 查询放函数内部,不在 import 阶段执行 |
| 修改模型后后台报错 | 没有执行迁移 | python manage.py makemigrations && migrate |
| 删除用户后评分数据全没了 | 外键on_delete=CASCADE误用 | 改为SET_NULL或软删除 |
| 两台机器合并代码后迁移冲突 | 迁移文件版本不一致 | 删除冲突分叉,重置单一迁移链 |
| 循环导入导致启动崩溃 | 模型间互相 import | 统一用字符串引用模型 |
| ORM 查询慢且日志里 SQL 爆炸 | N+1 查询 | select_related/prefetch_related |
关于迁移冲突,我有个经验:多分支并行开发时,不要各自在本地跑 migrate 后再合并,而应该合并代码后再统一执行makemigrations。否则迁移文件在合并时会产生多个 root 节点,数据库迁移顺序会错乱。删除迁移文件时务必谨慎,宁可重新跑一遍所有迁移也不要随意删除历史迁移。
5.2 推荐效果评估与参数调优
推荐系统做完了,怎么知道推荐得好不好?我用了两套评估方式。离线评估时,我用历史评分数据做训练集和测试集,按 8:2 切分,然后计算 RMSE 和 TopN 推荐的命中率。RMSE 反映评分预测的误差,命中率反映用户实际喜欢的物品是否出现在推荐列表里。这两个指标任何一个不合格都要回头调算法。
在线评估更简单可靠:看推荐位点击率。我在推荐接口里埋了点击日志,每三天统计一次推荐点击率。正常 IP 化的旅游推荐系统点击率在 5%~15% 之间。如果点击率长期低于 5%,不是算法出了问题,就是推荐列表被热榜支配了,需要检查冷启动兜底策略提供的热度结果占比是否太高,建议把热榜比例降到 30% 以下。
参数调优方面,我重点调三个值:协同过滤的邻居数量 K(推荐 20~50)、最小共同评分数量(推荐 3~5)、相似度阈值(推荐 0.3~0.4)。这些参数没有绝对标准,我在项目里用脚本遍历参数组合,以离线指标为基准选最优配置。比如把邻居数量从 10 调整到 30,命中率从 14% 升到了 21%,但继续增加到 50 又降到 19%,所以 30 左右是当前数据量的有效范围。
5.3 Ubuntu / Debian 服务器部署实操
开发完成后面临部署,这里分享一套我实测可行的部署路线,基于 Ubuntu/Debian 系 Linux 发行版。推荐生产环境搭配:Gunicorn + Nginx + Redis + PostgreSQL,这套组合在目前技术社区中非常成熟,国内云服务器也能轻松压住。
sudo apt update sudo apt install python3-venv python3-pip redis-server nginx postgresql cd /opt/travel_sys python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinputGunicorn 负责运行 Django 进程,Nginx 负责静态文件与反向代理。重点是/etc/systemd/system/travel.service这个 service 文件,日志输出和进程守护交给 systemd 管理:
[Unit] Description=travel system After=network.target [Service] User=www-data WorkingDirectory=/opt/travel_sys ExecStart=/opt/travel_sys/venv/bin/gunicorn travel_sys.wsgi:application -b 127.0.0.1:8000 Restart=always Environment="DJANGO_SETTINGS_MODULE=travel_sys.settings.prod" [Install] WantedBy=multi-user.target部署时最容易被忽略的是 Debug 开关和静态文件收集。生产环境务必将DEBUG=False,并设置ALLOWED_HOSTS,否则会暴露大量调试信息和安全隐患。Nginx 里配置好静态文件 alias 指向STATIC_ROOT,再把 API 请求反代到127.0.0.1:8000即可。
5.4 源码结构与后续扩展思路
源码目录组织如下,建议按顺序阅读:
travel_sys/ ├── analytics/ # 数据分析模块 │ ├── views.py # 统计视图 │ └── services.py # pandas 统计服务 ├── reviews/ # 评价模块 │ ├── models.py # Rating, Review │ └── views.py # 评价接口 ├── recommender/ # 推荐引擎模块 │ ├── algorithms/ # user_cf.py, item_cf.py │ ├── services.py # 推荐调度与混合策略 │ └── cache.py # 缓存读写 ├── travel_sys/ # 项目配置 │ ├── settings.py │ └── urls.py ├── scripts/ │ └── offline_update.py # 离线推荐计算脚本 └── static/ # 前端资源阅读路径建议:先看reviews/models.py了解数据结构,再看recommender/services.py理解推荐调度流程,接着看recommender/algorithms/item_cf.py掌握核心算法,最后看scripts/offline_update.py弄懂定时计算任务。
后续扩展可以做三个方向。第一是引入基于内容的推荐,用景点的描述文本做 TF-IDF 或词向量相似度,解决新景点冷启动;第二是接入真实游客行为日志埋点,用点击流数据替换简单评分做隐式反馈;第三是把实时推荐接口改造成异步任务,用 Celery 处理推荐计算,前端秒开、后端不影响。
这套系统虽然是旅游场景,但架构和算法完全可以迁移到电影、美食、酒店推荐项目。我把整个项目完整源码已经整理放出来了,拿到后先跑通requirements.txt再对照本文阅读。
最后分享一点个人体会:做推荐系统,最重要的不是算法炫技,而是把数据闭环做完整。用户产生行为、行为进入模型、模型生成推荐、推荐再收到反馈,这个环只要有一环断裂,推荐效果一定差。我在这个项目上把大量时间花在数据清洗和缓存设计上,而不是一味调算法参数,最终效果反而比预期好很多。如果你也在做类似项目,希望这篇记录能帮你少走一些我已经走过的弯路。