1. 项目概述与技术选型1.1 这个系统到底解决什么问题做旅游推荐系统的起因很简单每次出门前打开各种旅游App看到的推荐内容要么千篇一律是热门景区要么就是买过流量的商家在硬推真正符合自己口味的目的地少得可怜。传统的旅游网站更多是按照销量、热度排序本质上是一种头部流量分发用户需要自己从一堆信息里筛选。我做这个基于Python的旅游推荐系统就是想尝试用协同过滤推荐算法让系统根据用户的历史行为数据去学习每个人的偏好然后给出千人千面的景点推荐。这个项目不是一个玩具Demo而是一套完整的Web应用后端用Django框架数据层用的MySQL推荐模块实现了多种推荐算法。为了拿到真实可用的景点数据我还写了爬虫去采集公开的旅游网站信息把景点名称、评分、评论数、经纬度、标签等结构化字段存到本地。整套系统包含了数据采集、数据清洗、推荐引擎、Web展示和用户交互几个完整环节对应的源码和文档也一并整理好了。适合谁来参考如果你正在学机器学习但苦于找不到合适的练手项目或者课程设计、毕业设计需要一套能演示完整流程的系统再或者你想搞明白协同过滤这类推荐算法在实际Web项目里是怎么落地的这个项目的思路和代码都能给你省不少时间。后面我会把架构设计、算法原理、实操细节、踩坑记录全部展开讲。1.2 为什么选Django而不是FlaskWeb框架的选择我纠结过一阵子。Flask轻量灵活写个小接口很快但一旦涉及用户认证、后台管理、ORM、表单处理这些模块还是得自己拼装各种扩展库对于推荐系统这种重业务项目来说会分散不少精力。Django把Admin后台、用户认证、ORM、模板引擎这些常用模块都内置了而且自带一套比较成熟的MVT架构开发效率明显更高。更重要的一点是Django的ORM对模型层抽象做得比较干净。推荐系统核心要做的是把用户-景点-行为这些关系建好模型然后算法部分专注于计算逻辑两者之间通过ORM解耦。我在项目里定义了User、ScenicSpot、Rating、Recommendation结果表建表、迁移、关联查询都是一套命令搞定后期调整字段不用手写一堆SQL省心很多。1.3 多种推荐算法一起上的原因标题里写了多种推荐算法这确实不是噱头。协同过滤是核心没错但它有冷启动和稀疏性问题新用户没有历史行为时根本算不出相似用户新景点没人评分时也无法被推荐出去。所以我额外实现了热度推荐和基于内容的推荐作为兜底和补充。热门推荐按浏览量、评分人数、综合评分做加权排序冷启动阶段直接展示给新用户基于内容推荐根据景点类别、标签、所在地匹配用户偏好标签基于用户的协同过滤UserCF找到相似兴趣的用户群推荐他们喜欢的景点基于物品的协同过滤ItemCF找出与用户历史浏览过的景点相似的景点多套策略跑在同一套数据上各有用武之地。冷启动走热度、新用户问你要什么走内容推荐、老用户沉淀了行为后走协同过滤。这也是业界推荐系统的基本套路只是我把它们真的都写进了这一个项目里。2. 核心推荐算法拆解2.1 协同过滤的本质是人以群分物以类聚很多初学者一听协同过滤就发怵其实背后的道理特别朴素。基于用户的协同过滤核心假设是跟你兴趣相似的一群人喜欢的东西你大概率也喜欢。你想想平时朋友推荐电影、餐厅是不是都是这个逻辑系统要做的就是量化相似这件事然后自动做同样的事。在代码层面相似是用相似度公式算出来的。我首选的是余弦相似度Cosine Similarity公式和实现都不复杂。每个用户对景点的评分构成一个高维向量两个用户向量的夹角余弦值就是他们的相似度数值越接近1表示越相似越接近-1表示越相反。虽然还有皮尔逊相关系数、Jaccard相似度这些方案但余弦相似度对评分尺度差异不那么敏感实现也直观初版我用它跑通全链路后续要优化再逐个替换对比效果。计算步骤整理一下构建用户-景点评分矩阵针对目标用户遍历其他用户计算余弦相似度取Top N个相似用户作为邻居集合把邻居用户评分过的、目标用户没看过的景点按加权得分排序得分 相似度 × 评分的加权和归一化后取Top K作为推荐结果2.2 相似度计算的Python实现这部分代码是整个推荐引擎的地基。我用Pandas来构建评分矩阵避免了手动操作二维列表的各种痛苦。核心代码如下import pandas as pd import numpy as np from sklearn.metrics.pairwise import cosine_similarity def build_user_item_matrix(ratings_df): ratings_df: DataFrame至少包含 user_id, item_id, rating 三列 返回: 用户-景点评分矩阵行用户列景点 matrix ratings_df.pivot_table( indexuser_id, columnsitem_id, valuesrating ).fillna(0) return matrix def user_similarity(matrix): 计算所有用户之间的余弦相似度矩阵 # 注意sklearn 的 cosine_similarity 对行向量计算余弦相似度 # 行是用户列是景点所以直接传入矩阵即可 sim_matrix cosine_similarity(matrix) # 转成 DataFrame方便按用户名索引 sim_df pd.DataFrame( sim_matrix, indexmatrix.index, columnsmatrix.index ) return sim_df def recommend_for_user(user_id, matrix, sim_df, top_n10): 基于用户相似度矩阵为目标用户生成TopN推荐 if user_id not in matrix.index: return [] # 该用户还没评分的景点 user_rated set(matrix.loc[user_id][matrix.loc[user_id] 0].index) candidates set(matrix.columns) - user_rated # 找相似度最高的前20个用户邻居 neighbors sim_df[user_id].sort_values(ascendingFalse)[1:21] # 加权评分累加 scores {} for item in candidates: total_score 0.0 total_sim 0.0 for neighbor_id, sim_val in neighbors.items(): score matrix.loc[neighbor_id, item] if score 0: total_score sim_val * score total_sim sim_val if total_sim 0: scores[item] total_score / total_sim # 排序取TopN ranked sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return [item_id for item_id, _ in ranked]这一步有不少细节坑要提醒。空值一定要先填0再算相似度否则余弦相似度会因为NaN直接报错。评分矩阵通常非常稀疏很多用户只互动过几个景点如果某个候选景点只有一个邻居评过分加权得分可能虚高我在实现里加了total_sim归一化来压制这种情况。另外邻居数量不是越大越好我测试下来Top 20到Top 50效果比较稳定再往上会把大量弱相似用户的噪声带进来。2.3 基于物品的协同过滤ItemCF实现与区别基于物品的协同过滤是另一种视角先计算景点之间的相似度然后根据用户历史打分过的景点推荐与之相似的其他景点。它在用户行为较多但物品更新较快的场景下表现更好而且可解释性更强——你可以直接告诉用户因为你喜欢西湖所以推荐你西溪湿地。景点相似度计算方式和用户相似度一样只是把矩阵转置。转置后行是景点、列是用户余弦相似度算出来的就是景点之间的相似程度。然后推荐逻辑变为def recommend_items_based_on_history(user_id, ratings_df, item_sim_df, top_n10): 基于物品协同过滤 1. 找到用户打过分的景点集合 2. 对每个候选景点计算它和用户打过分的所有景点的相似度加权和 3. 排除用户已打分景点排序取TopN user_items ratings_df[ratings_df[user_id] user_id] rated_item_ids set(user_items[item_id]) if not rated_item_ids: return [] # 候选集所有景点除去该用户已评分的 candidate_items set(item_sim_df.columns) - rated_item_ids scores {} for candidate in candidate_items: score 0.0 for rated_item in rated_item_ids: sim item_sim_df.loc[rated_item, candidate] rating user_items[user_items[item_id] rated_item][rating].values if len(rating) 0 and sim 0: score sim * rating[0] scores[candidate] score ranked sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return [item_id for item_id, _ in ranked]电商领域多用ItemCF因为用户的行为数据远比景点丰富但旅游场景下一个用户一年也去不了几个地方评分矩阵极其稀疏UserCF反而更容易找到相似群体。我的实际测试也印证了这一点在只有几千条评价数据时UserCF的推荐结果命中率更高ItemCF的优势在数据量上来之后才逐渐显现。两种算法我都保留了在系统里可以通过配置项切换做效果对比很方便。2.4 冷启动问题的缓解方案冷启动是协同过滤的经典痛点我处理的方式很务实新用户注册后没有任何行为数据系统先展示热门榜单和基于地区、标签的内容推荐引导用户先做兴趣标签选择操作。用户选几个感兴趣的标签后基于内容推荐就能给出初步结果随着用户浏览、打分协同过滤才会慢慢接管。内容推荐代码不长核心就是标签向量匹配def content_based_recommend(user_id, user_tags, spot_df, top_n10): user_tags: dict形式如 {自然风光: 0.7, 人文历史: 0.3, 美食: 0.2} spot_df: 景点数据包含 tag_list 字段逗号分隔的标签 返回: TopN景点列表 def tag_score(row): tags str(row[tag_list]).split(,) s 0.0 for t in tags: if t in user_tags: s user_tags[t] return s spot_df spot_df.copy() spot_df[_score] spot_df.apply(tag_score, axis1) ranked spot_df.sort_values(_score, ascendingFalse).head(top_n) return ranked[spot_id].tolist()实际开发中我给每个景点打了3到5个标签标签体系是自己定的自然风光、人文历史、亲子游乐、美食购物、户外运动、主题乐园等。用户注册时勾选感兴趣的标签并设置权重系统按照权重加权匹配。3. 爬虫设计与数据采集全流程3.1 爬虫选型与数据字段设计推荐算法没有数据就像做菜没有食材。我的爬虫目标是采集城市景点的基础信息和用户评价数据。技术上选了Python的requests加BeautifulSoup没用Scrapy理由很简单数据量不算海量Scrapy的并发调度、中间件配置对这种中小型项目有点杀鸡用牛刀requests加解析库足够灵活调试也直观。数据字段设计直接决定后面算法能不能跑得顺。我的景点表长这样字段名类型说明spot_idint景点唯一标识namevarchar景点名称cityvarchar所在城市ratingfloat综合评分comment_numint评论数量tag_listvarchar多个标签逗号分隔addressvarchar详细地址latitude / longitudefloat经纬度introductiontext景点简介image_urlvarchar封面图片地址用户评分表则记录了user_id、spot_id、rating和timestamp模拟用户在系统内的打分行为。真实爬虫数据里通常没有用户评分评分表一部分来自爬取的评论内容做了文本情感映射一部分来自我组织的种子用户人工标注保证算法的输入数据可用。3.2 爬虫代码的核心结构和反爬应对基础爬虫流程很常规构造请求头、发送GET请求、解析HTML、提取数据、存储入库。关键代码骨架如下import requests from bs4 import BeautifulSoup import time import random import pymysql HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, Referer: https://www.example.com/ } def fetch_page(url): # 随机延时模拟真实用户访问节奏 time.sleep(random.uniform(1, 3)) resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 return resp.text def parse_spot_list(html): soup BeautifulSoup(html, html.parser) items [] for card in soup.select(.spot-card): name card.select_one(.spot-name).text.strip() rating float(card.select_one(.rating-score).text) comments int(card.select_one(.comment-num).text.replace(条评价, )) tags ,.join([x.text for x in card.select(.tag-item)]) items.append({ name: name, rating: rating, comment_num: comments, tag_list: tags }) return items def save_to_mysql(items): conn pymysql.connect(hostlocalhost, userroot, password******, databasetravel_recommend) cursor conn.cursor() insert_sql INSERT INTO scenic_spot(name, city, rating, comment_num, tag_list, address) VALUES (%s, %s, %s, %s, %s, %s) for item in items: cursor.execute(insert_sql, (item[name], item.get(city, ), item[rating], item[comment_num], item[tag_list], item.get(address, ))) conn.commit() cursor.close() conn.close()反爬简单提两句。我遵守规则的底线很清楚只抓公开信息、控制请求频率、不做任何绕过登录或验证码的操作。同时User-Agent要伪装成正常浏览器Referer字段补上访问间隔设置1到3秒随机延时。再加上遇到403就退避休息10分钟再继续。这套做法足够应对普通站点的基本反爬又不至于碰线。爬虫领域有句实话想要数据就绕不开反爬但千万不要做破坏性爬取个人项目保持克制。3.3 数据清洗的坑爬虫爬下来的原始数据真的不能直接用这一步我吃了不少苦头。景点名称前后带空格、评分字段混入暂无评分这类非数字文本、标签字段为空、坐标字段缺失都是常态。清洗逻辑单独放了一个data_clean.py处理的规则包括文本去首尾空格、统一全半角符号评分为空或非数字时置为0标记为未评分类评论数转int带万字单位时做换算只有一条评论的景点降权默认评分降到3.0以下防止噪声数据污染缺失经纬度的景点通过高德开放接口按地址补查需要申请一个免费的Web服务Key清洗的核心原则不是删干净而是知道每一处脏数据背后的业务含义。比如评论数极少的景点不代表不值得推荐可能是新上线信息所以我在算法里给它一个时间衰减因子让新景点有机会被推荐而不是直接埋没在冷数据里。4. Django系统集成与Web实现4.1 项目结构安排推荐系统最终要落地成产品Django项目结构我按模块化思路拆分。主项目目录叫TravelRecommend下面挂了三个应用TravelRecommend/ ├── manage.py ├── TravelRecommend/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── users/ # 用户注册、登录、画像管理 ├── spots/ # 景点展示、详情页 ├── recommend/ # 推荐引擎模块 │ ├── recommender.py │ ├── algorithms/ │ │ ├── user_cf.py │ │ ├── item_cf.py │ │ └── content.py │ └── utils.py └── templates/recommend应用不建议简单当成普通Django业务应用来写它是一个纯逻辑模块只在视图层被调用。算法部分的输入是ORM查询出来的DataFrame输出是景点ID列表这样就能把业务逻辑和推荐逻辑隔离开后续升级算法不用动Web层。4.2 Django模型设计模型是整个系统的心脏。我用三个核心模型构建数据关系from django.db import models class User(models.Model): username models.CharField(max_length50, uniqueTrue) password_hash models.CharField(max_length128) preferred_tags models.CharField(max_length255, blankTrue, default) created_at models.DateTimeField(auto_now_addTrue) class ScenicSpot(models.Model): name models.CharField(max_length100) city models.CharField(max_length50) rating models.FloatField(default0.0) comment_num models.IntegerField(default0) tag_list models.CharField(max_length255, blankTrue) address models.CharField(max_length255, blankTrue) latitude models.FloatField(nullTrue, blankTrue) longitude models.FloatField(nullTrue, blankTrue) introduction models.TextField(blankTrue) image_url models.CharField(max_length255, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table scenic_spot class Rating(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE) spot models.ForeignKey(ScenicSpot, on_deletemodels.CASCADE) rating models.FloatField(default0.0) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table user_rating unique_together (user, spot)unique_together这个约束非常关键能防止同一用户对同一景点重复评分。第一次没加这个约束测试时刷几次页面数据就乱套了算法结果直接崩。Django的ORM在建表时会自动生成联合唯一索引从源头把数据一致性兜住。密码字段存的是哈希不是明文虽然只是一个教学项目但该有的安全意识不能少。4.3 推荐接口与视图联动推荐视图的核心是把算法输出和HttpResponse接起来。用户在首页登录后系统会先检查用户是否有评分记录没有就走冷启动推荐有就调协同过滤算法。视图层大概长这个样子from django.shortcuts import render from .algorithms.user_cf import recommend_for_user from .algorithms.content import content_based_recommend from .models import Rating, ScenicSpot def home(request): user request.user if not user.is_authenticated: # 未登录直接给热门推荐 spots ScenicSpot.objects.order_by(-rating, -comment_num)[:10] return render(request, home.html, {spots: spots, strategy: hot}) ratings_df load_ratings_as_dataframe() matrix build_user_item_matrix(ratings_df) sim_df user_similarity(matrix) recommend_ids recommend_for_user(user.id, matrix, sim_df, top_n10) if not recommend_ids: # 冷启动兜底内容推荐 user_tags parse_tags(user.preferred_tags) recommend_ids content_based_recommend(user.id, user_tags, load_spot_dataframe()) spots ScenicSpot.objects.filter(id__inrecommend_ids) return render(request, home.html, {spots: spots, strategy: user_cf})在第一个版本中我在视图里每次请求都重新计算相似度矩阵。数据少的时候感觉不到数据量上来后页面响应越来越慢。后来加了一个简单的缓存装饰器相似度矩阵在内存里保留一小时用户评分有变化时再强制刷新响应时间从秒级降到毫秒级。这个优化思路其实就一句话不频繁做重计算把算好的结果缓存下来。4.4 前端页面与交互设计前端我用的是Django模板加Bootstrap没有上前后端分离架构。原因很简单这个项目的重点是推荐算法前端交互只需要保证页面干净、功能直接即可。页面一共四类登录/注册页首页推荐列表展示策略标签比如为你推荐热门精选景点详情页包含评分、标签、评论和相似推荐个人中心查看历史评分、修改兴趣标签首页推荐列表用了卡片布局每个卡片展示景点图片、名称、评分、标签。用户对推荐结果可以一键喜欢或不喜欢这个反馈会实时写入Rating表下次刷新推荐结果马上变化。这种显式反馈机制让用户感受到系统越用越懂我演示效果非常好。5. 常见问题与排查技巧实录5.1 评分矩阵稀疏导致的推荐质量差这是最常被问的问题。新系统上线没多少数据时评分矩阵稀疏率可能超过95%大多数用户之间的共同评分项为0算出来的相似度全是0或者极小值推荐结果跟随机排序差不多。我的解决思路分两步。第一步是填充策略相似度计算前对评分做均值中心化处理把每个用户评分减去该用户的平均分这样即使两个人共同评分项很少只要对各自打分尺度的偏差趋势一致余弦相似度也能反映出一定的相关性。第二步是降维降噪只保留至少有过N次评分行为的用户和至少被M个用户评过的景点滤掉那些只有一两条行为的边缘数据。N和M可以按数据规模调我这边设置的是N5、M3效果明显改善。5.2 爬虫数据入库后中文乱码乱码问题九成出在字符集不统一。requests拿到的网页编码是utf-8没问题但MySQL表如果建表时用的默认latin1保存中文直接变问号。我踩坑之后把所有表统一改成了utf8mb4Django的settings.py里DATABASES配置也加上OPTIONS: {charset: utf8mb4}。特别注意utf8mb4和utf8的区别utf8在MySQL里最多存3字节很多生僻字和emoji都存不下直接报错或者变乱码utf8mb4兼容性最好。检查文件编码也是个隐患点。Python源码文件如果没加# -*- coding: utf-8 -*-声明部分环境下字符串字面量的中文会解析失败。虽然Python 3默认就是UTF-8源码但CSV文件导入导出、日志输出这些环节还是要统一指定编码不然Windows环境下用记事本编辑过文件之后编码常常被改成GBK。5.3 Django部署后静态文件加载失败本地开发时静态文件没问题一上服务器全丢样式。原因是Django开发服务器会帮你自动处理静态文件但生产环境或者用python manage.py runserver --insecure之外的方式必须自己收集静态文件。解决办法是在settings.py里配置STATIC_URL /static/ STATIC_ROOT os.path.join(BASE_DIR, staticfiles)然后运行python manage.py collectstatic把所有App里的静态文件拷贝到staticfiles目录下再让Nginx或者你用的Web服务器指向这个目录。如果只是演示用直接让Django托管也可以跑通但不建议正式部署时这么干性能和安全性都有问题。5.4 推荐结果重复和覆盖率不足做出来的推荐列表如果连续好几页都是同一个城市的景点、或者点击了相似景点之后永远推同质化内容用户体验非常差。我做了两件事来缓解一是推荐列表里对城市和景点类别做配额限制比如Top10里最多3个同城市、最多2个同标签的景点强制多样性二是在评分更新时引入探索系数有一定概率把热度榜上的冷门景点混进推荐列表让用户看到一些意外惊喜。多样性分数可以简单量化公式我用了平均类别距离即列表中随机两个景点标签不一致的比例。目标是把多样性保持在0.3到0.5之间太高显得杂乱太低则显得单一。6. 项目复盘与个人使用体验6.1 数据驱动下的算法效果对比把三种推荐算法跑在清洗过的数据上我用离线评价指标对结果做了对比评估。核心指标是精确率和召回率把用户行为数据按8:2划分训练集和测试集在测试集上统计推荐命中比例。结果符合预期数据稠密时UserCF和ItemCF的精确率都能到15%以上而内容推荐稳定在8%左右。数据稀疏时ItemCF下线快UserCF由于群体效应的存在还能勉强维持。这个结果说明没有万能算法选型必须看数据特征。线上侧的体验评价我用了用户点击率。加了协同过滤之后首页推荐卡片的点击率比纯热度排序提升了大约30%评分反馈数量也随之增加形成了数据飞轮的雏形。虽然这只是一个教学项目但这个正反馈循环让我确信推荐系统确实发挥作用了。6.2 换个角度爬虫和推荐系统的天然配合一般教学代码会把爬虫和推荐系统分开讲但实际做项目的时候你会发现它们是一对紧密配合的组合。没有爬虫采集的基础数据推荐系统就是空壳没有推荐系统采集下来的数据只能躺在数据库里吃灰。爬虫负责把世界的信息变成系统的数据推荐系统负责把系统的数据变成用户的体验。这两位之间的桥梁是数据清洗和特征工程。很多入门者重视算法模型却不重视数据管道质量结果模型再漂亮也喂不进数据。我建议做这种完整项目时把打通全链路的优先级放在算法调优之前。先把网站页面响应→爬虫采集→清洗入库→Django展示→推荐召回→用户反馈这条链路完整走通后面一切优化才有前提。6.3 后续可以扩展的方向这个系统目前还有不少可以继续深挖的地方。一是引入深度学习模型做召回排序比如用双塔模型把用户和景点的特征向量化做大规模候选集召回再用精排模型做CTR预估这条路可以无缝衔接业界实践。二是把实时行为流接入用户浏览一个景点后立刻更新推荐这需要引入消息队列或Redis之类的实时计算组件。三是加一个可解释性模块让系统在推荐时告诉用户因为你有西湖的高评分我们推荐了太湖这类理由实际产品的接受度会显著提升。我个人实际操作中的体会是推荐系统这个领域公式和论文多如牛毛但真正让你长进的是亲手把一个完整项目从零到一搭起来的过程。踩过的坑、调过的参数、看过的数据分布这些经验比任何理论都值钱。如果你也想做类似的系统建议先别追求算法复杂把协同过滤跑通、把爬虫管好、把Django界面做出来你收获的东西一定比想象中多。最后再分享一个数据采集阶段的细节技巧爬虫采集尽量选择夜间低峰时段请求间隔的随机数范围稍微放大一些遇到服务器返回500错误时先别急着重试先人工确认一下页面是否改版了。我在开发中因为页面结构变化导致解析器失效的次数比被反爬拦截的次数多得多这几乎是所有长期运行爬虫都会遇到的常态。