1. 校园美食场景下,为什么K-Means比协同过滤更友好
1.1 经典推荐算法在课设中的现实困境
每年到了课程设计和毕业设计选题的时候,"推荐系统"都是最热门的方向之一。你去看知网上一堆本科论文,十篇里有三篇是某某推荐系统的设计与实现,剩下七篇是某某管理系统的设计与实现。但真正动手做你会发现,推荐系统这个看似高大上的选题,落到校园美食这个垂直场景里,绝大多数经典算法都跑不起来。
为什么?先说说协同过滤。UserCF(基于用户的协同过滤)和ItemCF(基于物品的协同过滤)的核心思想都是统计用户的历史行为——评分、点单、收藏——然后计算用户之间或物品之间的相似度。这需要一张足够稠密的用户-物品行为矩阵。但校园美食场景下,菜品总量撑死几十上百种,用户群是本校几百个学生,而且绝大多数用户不可能对每个窗口的菜品都评过分。结果就是矩阵稀疏得惨不忍睹,算出来的相似度全是空值,推荐质量根本没法看。
我当年做这个题目的时候,第一版想的也是ItemCF,后来跑出来的结果让我直接放弃了:大部分用户的推荐列表里全是热门口味的大众菜,没有任何个性化可言。后来我把方案换成了K-Means聚类,整个推荐链路瞬间顺了很多,算法复杂度也降下来了。这篇文章就把我完整跑通的思路、代码结构、数据表设计,以及答辩时被老师反复追问的细节,全部拆开讲清楚。
1.2 K-Means的定位:用口味聚类替代共生矩阵
K-Means的原理其实特别朴素,初中生都能听懂:把一群人按"口味相似度"分成几个圈子,圈子内部的人饮食偏好接近,圈子之间差异明显,然后新来的人看他更像哪个圈子,就用那个圈子的人爱吃的菜去推荐。
用聚类做推荐的底层逻辑和协同过滤完全不同。协同过滤依赖用户之间的显式行为重叠,而K-Means只需要你把每个用户描述成一组特征向量,然后让算法自己去发现"兴趣团体"。这意味着,就算你的行为数据很少,只要有特征向量,照样能聚类、能推荐。这在课设数据规模下简直是救命稻草。
具体到校园美食场景,特征向量的构造方式非常灵活。我用的方案是把每个用户表示成几个维度的偏好值:对辣的耐受度、对甜口的偏好度、平均每餐可接受价格、荤食偏好程度、对油炸食品的偏好度等。这些值可以从用户注册时的口味标签、历史评分、点单记录里算出来。菜品也可以用同样的维度空间去表征,这样用户和菜品就落在同一套坐标系里,后面做推荐就好办多了。
1.3 冷启动与可解释性:课设答辩的加分点
用K-Means还有一个隐性好处——冷启动和可解释性都天然解决了。
冷启动方面,传统协同过滤最怕新用户没有任何历史行为。但K-Means方案里,新用户注册时填一下口味偏好问卷(其实就是选几个标签),就能构造出初始特征向量,然后直接丢进训练好的模型里预测簇归属,马上就有推荐结果。这也意味着你的系统demo可以随时给答辩老师现场演示注册新账号,不用提前刷一堆数据。
可解释性就更妙了。答辩的时候老师问你"你的推荐依据是什么",协同过滤的回答是"因为相似度高的用户也点了这个",这个解释绕来绕去,老师不一定满意。而K-Means的回答是"我们把你分到了第2簇,这个簇的共同偏好是口味偏辣、人均15-20元、爱吃川湘菜,所以给你推荐以下菜品",这种可解释性对评委来说非常直观,也是你毕业论文创新点包装的绝佳素材。
注意:K-Means不是万能的,它做的是"口味分群"而不是"精确预测"。如果你志在做一个生产级推荐系统,那还得上矩阵分解或深度模型,但作为课设/毕业设计,K-Means无论是在工程量、算法理解难度还是答辩说服力上,都是性价比极高的选择。
2. 系统总体架构与数据库设计
2.1 功能模块拆分
整个系统走的是Django最经典的MTV模式,前后端不分离,页面渲染用Django模板,接口部分用JsonResponse返回,兼顾展示和交互能力。功能模块我拆成了四个app,各管一摊:
- users:用户注册、登录、个人口味标签维护。
- dishes:菜品信息管理(名称、所属食堂/窗口、类别、价格、口味标签、图片、评分),附带Django Admin做后台增删改查。
- ratings:用户对菜品的评分和评论,这是构造特征向量的原始数据来源。
- recommend:核心推荐模块,负责特征提取、聚类训练、推荐结果缓存与展示。
模块划分的原则很简单:算法相关的代码隔离在recommend里,不要混到views里写,不然写到后面你自己都找不到逻辑在哪。
2.2 核心数据表设计
数据库直接用Django内置的SQLite就够用了,不需要上MySQL。但表结构必须设计清楚,下面是我最终用到表,每张表都有存在的理由:
| 表名 | 关键字段 | 作用说明 |
|---|---|---|
| UserProfile | user(OneToOne)、spicy_level、sweet_level、price_sensitivity、meat_preference、fry_preference | 用户扩展表,存口味特征向量所需的基础偏好数据 |
| Dish | name、canteen、category、price、spicy_level、sweet_level、meat_or_veg、is_fried、avg_rating | 菜品主表,字段设计尽量让菜品的特征空间和用户对齐 |
| Rating | user(FK)、dish(FK)、score、comment、created_at | 用户评分记录,用来动态修正用户偏好 |
| ClusterResult | cluster_id、center_vector、member_count、created_at | 聚类结果缓存表,避免每次请求都重新训练 |
| DishRecommand(中间产出) | cluster_id、dish(FK)、rank_score | 每个簇对应的推荐菜品Top-N,训练完成后持久化存储 |
2.3 技术栈与版本选型
版本匹配这个问题,看起来不起眼,但每年能卡住一批新手。我测试过的组合如下,互相很兼容:
- Python 3.10
- Django 4.1.x
- scikit-learn 1.2.x
- numpy 1.24.x
- pandas 1.5.x
特别要提醒的是:sklearn和numpy的版本绑定非常紧,网上很多教程让你直接pip install sklearn,结果numpy版本冲突,import就报错。我的建议是装的时候指定版本,比如pip install scikit-learn==1.2.2 numpy==1.24.3,一次性装好,比出了错再排查省事得多。
Django版本不要选太新的(5.x出了之后有些第三方库的兼容还没跟上),4.1这个版本文档多、生态稳,踩坑了随便一搜就能找到答案。Python也别用3.12,有些老版本的编译好的依赖装不上,用3.10最省心。
3. 核心算法:从用户行为到推荐结果的完整链路
3.1 特征向量的构建:把"吃没吃过"变成"可计算的数字"
这是整个系统最关键的一步。K-Means吃进去的是数值矩阵,所以你得先把用户和菜品都翻译成一个固定维度的向量。
我用的维度一共5个,设计的时候遵循"少而可解释"的原则:
- spicy_level:辣度偏好,0-5连续值,0是完全不吃辣,5是无辣不欢。
- sweet_level:甜口偏好,0-5。
- price_sensitivity:价格敏感度,我把它定义为人均可接受价格除以学校食堂平均价,小于1说明偏向便宜窗口,大于1说明消费水平偏高。
- meat_preference:荤食偏好,0-1之间的小数,越接近1越爱点荤菜。
- fry_preference:油炸偏好,0-1。
用户的这几个值怎么来?三个来源:
- 注册问卷里用户可以自评辣度和甜度(1-5分制互斥);
- 历史评分记录里,Dish表同样存了每道菜在这5个维度上的取值,用户评过分的菜,对应维度加权平均,就得到行为修正后的偏好值;
- 把问卷值和行为修正值做一个加权融合,问卷权重0.4,行为权重0.6,防止用户随手乱填导致特征失真。
菜品端的特征向量构造就简单多了:录入菜品时直接按同一个五维空间打标签。比如"麻辣香锅"就是spicy=5, sweet=0, price=1.2, meat=0.8, fry=0.6。不需要用户相似度矩阵,也不需要奇异值分解,思路完全透明。
3.2 数据预处理与K值选择
特征向量构造完之后,不能直接丢进KMeans,必须先做标准化。原因很简单:价格敏感度的数值范围可能是0.5到2.5,辣度只有0到5,K-Means是基于欧氏距离的,数值量纲大的特征会主导距离计算,价格维度直接把口味差异淹没了。
我用的StandardScaler做z-score标准化,代码就两行:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() user_features_scaled = scaler.fit_transform(user_features)这里注意一个细节:fit_transform是对全体用户一起做的,训练完保存scaler对象,后面新用户注册进来预测簇时,只能用scaler.transform,不能重新fit,否则分布一变,簇的划分就漂移了。
K值的选择我用了最常见的手肘法加轮廓系数双重验证。手肘法看的是每个K值对应的SSE(簇内误差平方和),找到下降趋势明显变缓的拐点;轮廓系数则衡量每个样本对自身簇和相邻簇的贴近程度,取值[-1,1],越大越好。我在自己的数据集上跑出来的结果是K=4最合适,也就是全校用户大概能分成四类口味人群:清淡养生党、无辣不欢党、甜口奶茶党、啥都吃干饭党。
3.3 聚类与推荐生成
算法核心部分代码不长,但每一步都有讲究:
from sklearn.cluster import KMeans kmeans = KMeans(n_clusters=4, random_state=42, n_init=10) kmeans.fit(user_features_scaled) # 新用户推荐 new_user_vector = build_feature_vector(request.user) new_user_vector_scaled = scaler.transform([new_user_vector]) cluster_id = kmeans.predict(new_user_vector_scaled)[0]聚类训练完之后,怎么生成推荐列表?我的做法分三步:
- 把每个簇里的用户评过分且分数>=4的菜品统计出来,按出现频次排序;
- 再用菜品向量和该簇簇中心向量做一次余弦相似度排序,取Top-N;
- 两者加权合并,频次权重0.6,相似度权重0.4,得到最终推荐序。
这个组合方式比单纯用某一项靠谱:只看频次会变成全小区间热门榜,一点都不个性化;只看向量相似度又会把一些明明在簇里没人吃过的冷门菜推上去。加权的思路说白了就是"你们圈子都爱吃 + 口味上确实很配"双重把关。
训练和推荐结果不允许实时跑。聚类模型训练是个离线任务,定义成Django自定义管理命令,数据量变了就手动python manage.py update_clusters执行一次,执行完把每个簇的推荐菜写进DishRecommand表。线上推荐接口只查表,不做任何计算,响应速度毫秒级。
4. Django工程落地的关键实现与避坑
4.1 项目结构与自定义管理命令
工程初始化阶段,有两条命令你要记牢:
# 创建项目 django-admin startproject campus_food # 在项目内创建四个app python manage.py startapp users python manage.py startapp dishes python manage.py startapp ratings python manage.py startapp recommend新手经常犯的错是忘把app写进INSTALLED_APPS,或者改完models不跑makemigrations和migrate,这些属于常规操作我就不啰嗦了。我要重点讲的是自定义管理命令,这是整个算法模块和Django结合的桥梁。
更新聚类结果的命令文件放在recommend/management/commands/update_clusters.py,结构大概是:
from django.core.management.base import BaseCommand from recommend.services import build_features, train_and_save_clusters class Command(BaseCommand): help = '训练K-Means模型并更新推荐缓存' def handle(self, *args, **options): user_features = build_features() train_and_save_clusters(user_features) self.stdout.write(self.style.SUCCESS('聚类模型更新完成'))这里有个生产环境里常见的需求:如果不想每次手动跑命令,可以把命令挂到Django的定时任务里。我测试的时候用的是django-crontab,Linux下配个cron表达式就行,但注意Windows开发环境下cron不生效,课设演示阶段还是手动执行更稳妥。
4.2 推荐接口与后台管理
推荐列表接口我写在了recommend的views里面,走的是类视图。
import json from django.http import JsonResponse from django.views import View from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from .models import ClusterResult @method_decorator(login_required, name='dispatch') class RecommendView(View): def get(self, request): # 查当前用户所在簇(在登录时或注册时已经算好) cluster = request.user.userprofile.cluster dishes = DishRecommand.objects.filter( cluster_id=cluster.cluster_id ).select_related('dish')[:10] data = [{ 'name': d.dish.name, 'canteen': d.dish.canteen, 'price': str(d.dish.price), 'score': str(d.dish.avg_rating) } for d in dishes] return JsonResponse({'code': 0, 'data': data})这里有两个细节容易被忽略:
一是select_related优化。查推荐列表时如果你要靠外键拿菜品信息,不打这个标记的话,N条推荐菜就是N次查询,加了之后一次联表查询搞定。课设虽然数据量不大,但代码里写出这种优化点,答辩的时候说出去很加分。
二是中文乱码问题。JsonResponse默认能处理中文,但如果你用了json.dumps自己拼字符串,记得加上ensure_ascii=False。我第一次做的时候接口返回的是\u4e2d\u6587这种转义字符,页面直接展示成了编码串,这个坑可以说十个人里有八个踩过。
后台管理方面,我给Dish、Rating都注册了Django Admin,在admin.py里用list_display、list_filter把字段展示优化了一下。菜品图片上传要注意在settings.py里配置好媒体文件路径:
MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'然后在项目级urls.py里加:
from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)不加这个配置,开发环境下图片永远403,上传功能直接白瞎。
4.3 我踩过的几个坑
第一个坑是sklearn版本和Python版本匹配问题,前面已经提过。第二个坑是Django的CSRF验证。如果你做了前后端分离,用fetch或者axios从页面向接口发POST请求,Django默认会拦你,报403 CSRF验证失败。课设如果页面和接口是同源的(同一个Django服务),最简单粗暴的办法是从cookie里取csrftoken塞到请求头里:
// 页面内获取CSRF Token const csrftoken = document.cookie .split('; ') .find(row => row.startsWith('csrftoken=')) ?.split('=')[1]; fetch('/api/recommend/', { method: 'POST', headers: { 'X-CSRFToken': csrftoken }, // ... });如果你偷懒想直接用csrf_exempt装饰器去掉验证,我劝你不要。一是答辩时容易被问CSRF是什么、为什么不用,二是这在真实项目里是安全漏洞,没必要为了省事给自己埋雷。
第三个坑是Django的delete()操作容易出关联问题。比如你要在admin里删一个菜品,如果其他表里有外键指向它,默认的PROTECT模式下会直接拒绝删除,报ProtectedError。这个其实是个设计好的保护机制,但新手看到报错会以为是bug。你需要在设计模型时想清楚每个外键的on_delete行为——用户删除时级联删他的评分用CASCADE,菜品被评过分则用SET_NULL保留评分记录,字段要设null=True。改完别忘重新migrate。
还有跟token和登录态相关的:我用了Django自带的login_required装饰器,登录态默认走session。如果你想做"记住我"功能,request.session.set_expiry()设置过期时间,课设要求不高这样完全够用。如果你用了rest_framework想上JWT,那又是另一个量级的复杂度了,不建议在课设阶段引入。
4.4 可选升级:WebSocket实时推送
如果你的毕设想冲个高分,可以在最后附加一个WebSocket实时推送的功能。场景是:当用户给某道菜打了高评分后,如果他的簇里其他人也喜欢这道菜,后台可以实时推送"和你口味相同的人也在吃这道菜"。
Django官方推荐的通道是Channels库,实现思路也不难:前端用WebSocket连接,后端加一个consumer,评分保存后触发group_send,把消息推给该用户所在簇的所有在线用户。这个功能演示效果非常惊艳,老师会以为你做了实时推荐系统,但工程量也就多一天。
不过我要提醒一句:别为了这个功能把项目复杂度拉爆。Channels需要ASGI部署,Daphne或Uvicorn,跟常规的WSGI部署还不一样,如果配置没弄好,整个项目在Windows上开发时经常起不来。我的建议是:WebSocket做加分项,放在核心功能全部稳定之后再加,加不上也不影响主线。
5. 课程设计答辩与文档包装经验
5.1 系统功能演示的完整链路
从零开始演示系统前,我建议你保证一条最顺滑的路径:
- 进入登录页,注册一个新账号;
- 注册问卷页勾选口味标签(选"偏辣、爱吃肉、人均15-20");
- 进入首页,展示的推荐列表十几秒内出现,里面全是川湘菜和烤肉饭;
- 点击任一菜品,跳转详情,看到口味标签、评分、食堂位置信息;
- 去后台管理页面,管理员身份删除一条菜品,演示数据完整性保护;
- 最后切换另一个口味(比如清淡)的账号,展示推荐列表明显变化。
这条链路走下来不超过五分钟,但覆盖了用户模块、推荐算法、后台管理、数据一致性四个核心功能。演示之前记得预置好数据:至少30道菜覆盖五个口味维度,每个维度上都有明显的差异化菜品,不然聚类出来的簇分不开,推荐效果就砸了。
5.2 万字文档的结构与高频答辩问题
万字文档听起来吓人,但真正实际写起来就那么几个大块。我建议的章节结构是:
- 第一章 绪论(1.5k字):背景、国内外研究现状、课题意义。研究现状部分千万别花大篇幅写深度学习,你的系统用的是K-Means,重点写聚类推荐和传统推荐算法的对比;
- 第二章 相关技术介绍(1.5k字):Django框架、K-Means算法原理、SQLite数据库;
- 第三章 需求分析(1.5k字):功能性需求(注册登录、菜品管理、评分、推荐)、非功能性需求(响应时间、并发量、安全性);
- 第四章 系统设计(2k字):总体架构图、数据库E-R图、表结构、算法流程;
- 第五章 系统实现(2k字):核心功能截图加关键代码,代码只贴核心的,每段配两行注释说明逻辑;
- 第六章 测试(1k字):功能测试用例表加结果,把登录、注册、推荐、评分、管理员删除这几条用例走一遍;
- 第七章 总结与展望(0.5k字):就说解决了什么问题、哪些地方还可以改进。
图表是整个文档的灵魂。数据表结构用E-R图画出来,算法这部分画一张K-Means聚类的示意图,系统架构画一张分层的模块图。这三张图下来,老师对你的评价直接上一个档次。可以用draw.io或者ProcessOn画,导出PNG插进去。
答辩的时候最容易撞上的问题,基本都在下面这张表里。
| 老师高频问题 | 推荐回答思路 |
|---|---|
| 为什么不用协同过滤? | 协同过滤依赖用户行为交叉覆盖,校园数据稀疏,计算出的相似度可信度低;K-Means按口味特征分群,不受矩阵稀疏影响 |
| K值是怎么确定的? | 手肘法看SSE拐点,轮廓系数验证,我在实验数据上K=4最佳 |
| 如果新用户没有行为数据怎么办? | 注册问卷构造初始特征向量,行为累积后加权修正,这也是系统的冷启动方案 |
| 你的推荐结果怎么评估好坏? | 说明是规则验证+人工评估:抽取测试用户,对比推荐菜品与其历史高分菜品的口味标签重合度 |
| 聚类算法有哪些缺陷? | 对初始质心敏感,我用了random_state=42固定和n_init=10多次初始化降低随机性;另外K-Means假设簇是凸的,真实口味分布不一定符合 |
答辩时有个通用技巧:提问之后先别急着答,把问题复述一遍,然后用"我们系统是这样处理的"来开头,先讲你采用的方案,再讲为什么不做备选方案。这会让老师觉得你不仅做了实现,也做了选型对比,这才是课设论文该有的深度。
写在最后的一点经验
说了这么多,最后分享一个我自己真正用过的技巧:在推荐接口的返回值里加上一个reason字段。后端生成推荐列表时,把该簇的共同偏好描述拼成一句人话返回,比如"第2簇用户口味画像:偏辣、荤食、人均15-20元"。前端页面直接把这句话展示成推荐理由。
这个成本几乎为零,但效果特别好。答辩老师看到这句文案,会觉得你的系统"能讲出推荐逻辑",比干巴巴的菜品列表有说服力得多。我在最后demo时,老师就顺着这句话追问了特征工程的设计思路,之前的准备全部派上用场。
做课设也好,做毕设也好,技术难度从来不是唯一的评分维度。把推荐链路讲清楚、把数据表设计合理、把核心算法的选型逻辑论证充分,就已经能拿到一个非常不错的成绩了。希望这篇分享能帮你少走点弯路,把时间花在真正拿分的地方。