简介:一份基于Django与K-means算法的校园美食推荐系统完整项目资料,面向需要完成毕业设计或课程设计的计算机专业学生,解决推荐系统开发、论文撰写与部署演示等痛点。资源共220个文件,约14.52MB,以Python后端(34个py)、Vue前端(38个vue)、JavaScript脚本(32个js)及大量图片素材为主,并附带Word格式万字论文、PPT、bat一键部署脚本和录制讲解视频,便于快速运行和理解。系统围绕用户管理、美食展示、K-means推荐算法等模块展开,内容涵盖绪论、相关理论、系统架构与数据库设计、算法实现与优化、系统测试等章节,提供从环境配置到部署验证的完整链路。目前已有62人学习下载,适合作为算法应用类毕业设计的参考蓝本或实训教学案例。
1. 校园美食推荐为什么要用 K-means:它不猜你喜欢什么,而是先找到和你口味相近的那群人
拿到“django基于k-means算法的校园美食推荐系统”这个标题时,我第一个反应不是去看算法,而是想这个场景为什么要用聚类而不是协同过滤或者深度学习。校园美食推荐有个非常典型的特点:用户群体高度同质,作息时间、消费区间、口味偏好都有明显的聚集性——早八党、夜宵党、重辣党、清淡党,本质上就是几簇人。K-means不关心某一个用户下一秒想吃什么,它只负责把口味相近的人分到一组,然后在这一组里找公认好吃的菜推荐出去。这套逻辑在校园场景里比“猜你喜欢”更稳。项目本身是一个 Django 后端工程,用 K-means 对用户行为做聚类,再基于簇内偏好生成推荐列表,适合正在做毕业设计、课设,或者想低成本验证“推荐系统 + 聚类”思路的开发者参考。后面我把这套系统的数据建模、聚类代码、推荐逻辑和踩坑点完整拆开讲,给的是可以直接照跑的方案。
2. 先搭数据地基:Django 模型、三张核心表与行为矩阵的生成
2.1 用户-菜品-评分建模:为什么不用单一评分表
先想清楚一个问题:校园美食推荐系统的输入是什么?K-means 聚类不是凭空聚的,它需要每个用户有一个可以被量化的行为特征向量。这个向量从哪来——从用户对菜品的评分、下单次数、口味标签里来。很多同学拿到这个标题直接建一张表存评分,跑 K-means 时发现特征不够用,这是第一个翻车点。
我一般会建三张核心表:用户表、菜品表、评分/行为表。用户表不存推荐结果,只存基础信息;菜品表存菜名、档口、分类、标签;评分表是关键,它不能只存一个分数,最好把“评分值、下单次数、最近一次消费时间”都记下来。原因是 K-means 的特征向量里,评分均值代表口味偏好强度,下单次数代表消费频次,这两个维度在聚类时区分度非常高。
# models.py 核心表设计 from django.db import models class User(models.Model): student_id = models.CharField(max_length=20, unique=True) campus = models.CharField(max_length=50, default='主校区') created_at = models.DateTimeField(auto_now_add=True) class Dish(models.Model): name = models.CharField(max_length=100) stall = models.CharField(max_length=100) # 档口 category = models.CharField(max_length=50) # 川菜/粤菜/小吃 tags = models.JSONField(default=list, blank=True) # ["辣", "下饭"] avg_rating = models.FloatField(default=0.0) class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) dish = models.ForeignKey(Dish, on_delete=models.CASCADE) score = models.FloatField() # 用户评分,1-5 order_count = models.IntegerField(default=1) # 下单次数 last_order_at = models.DateTimeField(null=True) class Meta: unique_together = ('user', 'dish')注意评分表里我加了unique_together,同一个用户对同一个菜品只能有一条行为记录,每次下单就更新order_count和last_order_at,而不是插入新行。这样建特征矩阵时不会出现重复行导致聚合结果翻车。score存原始评分,后续会做归一化,这里不需要提前处理。
实际项目里,很多开发者把“评分”表做成“订单”表,通过订单明细反推用户偏好,也能跑通,但聚合 SQL 会复杂很多。我建议还是保留独立的评分表,因为聚类前要做的是“用户 × 菜品”的透视表,评分表直接pivot就能出来,省掉一层关联查询。
2.2 菜品标签与档口信息:聚类结果的“可解释性”全靠它们
K-means 聚类完成后,每个簇会得到一个中心点,但光看中心点没法告诉用户“你们这组人喜欢吃啥”。要让推荐结果能落地,必须把菜品标签和档口信息挂进去。比如聚出来的第 2 簇中心点显示“麻婆豆腐、水煮鱼”权重很高,那这一簇的特征就可以打上“重辣偏好”,推荐时优先推这个簇内高评分菜品的同标签菜。
菜品表的tags字段别用简单逗号分隔存字符串,后续查标签权重时会写得很难受。我用JSONField存["辣", "下饭", "食堂二楼"]这种结构,聚类后做解释时直接遍历即可。这里有个小习惯:标签尽量控制在 3 到 5 个之间,太少了区分度不够,太多了后面做标签权重统计时数据稀疏。
# 给菜品写标签的辅助函数 def get_dish_feature_vector(dish, ratings_by_user): # 返回这道菜在一批用户行为下的特征:评分均值、被点次数、标签命中数 scores = [r.score for r in ratings_by_user[dish.id]] return { 'avg_score': sum(scores) / len(scores) if scores else 0, 'order_count': sum(r.order_count for r in ratings_by_user[dish.id]), 'tag_hits': len([t for t in dish.tags if t in ['辣', '清淡', '夜宵']]) }这是我自己写数据脚本时常用的辅助函数,它不直接进训练,而是用来生成“菜品维度的特征缓存”,推荐阶段需要解释“为什么推这个菜”时直接读缓存。
2.3 数据准备脚本:从数据库到 K-means 能吃的矩阵
Django 的 ORM 很适合直接操作数据库,但把数据变成 K-means 能吃的numpy矩阵需要写一个独立的准备脚本。核心是三步:取用户列表 → 取每个用户的菜品评分列表 → 按“用户 × 菜品”维度填入矩阵。不能直接在 Django 视图里写这段,耗时太长,我会用management command跑离线任务。
# management/commands/prepare_matrix.py import pandas as pd from django.core.management.base import BaseCommand from apps.recommend.models import Rating class Command(BaseCommand): def handle(self, *args, **options): rows = Rating.objects.values('user_id', 'dish_id', 'score', 'order_count') df = pd.DataFrame.from_records(rows) # 用户 × 菜品透视,值为评分和下单次数的加权组合 pivot_score = df.pivot_table(index='user_id', columns='dish_id', values='score', aggfunc='mean').fillna(0) pivot_count = df.pivot_table(index='user_id', columns='dish_id', values='order_count', aggfunc='sum').fillna(0) # 加权:评分是偏好,次数是频率,两者都重要 matrix = pivot_score.values * 0.7 + pivot_count.values * 0.3 pd.DataFrame(matrix).to_csv('data/rating_matrix.csv', index=False) self.stdout.write(f'matrix saved, shape={matrix.shape}')参数说明:评分权重0.7、次数权重0.3不是写死的,要看你业务里评分稀疏还是订单稀疏。如果校园用户很少打分,基本靠点单,那要调成0.3/0.7;如果评分数据充足,可以0.8/0.2。这个矩阵就是后面 K-means 唯一的输入。
3. K-means 聚类落地:K值怎么定、特征为什么必须标准化、模型怎么存
3.1 标准化:不看这一步 K-means 会全部被“点餐次数”带偏
K-means 算的是欧氏距离,特征量纲不同会让距离被大数值维度主导。比如评分是 1 到 5,点餐次数可能是 0 到 50,直接算距离的话,点餐次数的影响会是评分的十倍以上。聚类结果就是“一周点十次的人”被聚在一起,而不是“口味相似的人”聚在一起。这个坑几乎所有第一次用 K-means 做推荐的人都会踩。
标准做法是用StandardScaler对矩阵做标准化。注意:不能用MinMaxScaler,因为 K-means 的均值中心在极端值下会被拖偏,StandardScaler把数据变成均值为 0、方差为 1 的分布,聚类中心更稳定。
# cluster_users.py 标准化与K-means训练 from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans import pandas as pd df = pd.read_csv('data/rating_matrix.csv') # 只对有行为的列标准化,全0列留着会让距离计算没意义 active_cols = df.columns[df.sum(axis=0) > 0] scaler = StandardScaler() X_scaled = scaler.fit_transform(df[active_cols]) # 先固定K值跑一次,后面会讲怎么选K kmeans = KMeans(n_clusters=5, random_state=42, n_init=10) kmeans.fit(X_scaled) # 把簇标签写回用户表,便于推荐时查询 df['cluster'] = kmeans.labels_ df.to_csv('data/user_clusters.csv')逻辑说明:先过滤全 0 列,是因为很多菜品只有极少数人吃过,保留全 0 列会让这些菜在距离计算中变成“无信号噪声”。n_init=10是让 K-means 从 10 个不同的初始中心开始跑,选最优结果,避免一次随机初始化陷入局部最优。random_state=42固定随机种子,保证每次结果可复现。
调参重点:n_init在 sklearn 1.4 之后默认变成 10,老版本默认是 1,直接用老版本跑出来的聚类会很不稳定,每次结果都变,这是很多同学说“聚类结果玄学”的主要原因。
3.2 选 K 值:肘部法则加轮廓系数,别只信一个
K-means 要先指定 K 值。校园美食场景 K 值通常在 3 到 8 之间,但具体取几需要验证。只看“损失函数越小越好”会得到“K 等于用户数”的荒谬结果,因为每个用户自己一簇当然损失最小。我一般同时看两个指标:inertia(簇内平方和)的肘部拐点,和silhouette_score(轮廓系数)的最大值。
from sklearn.metrics import silhouette_score K_range = range(2, 11) inertia_list = [] silhouette_list = [] for k in K_range: km = KMeans(n_clusters=k, random_state=42, n_init=10) km.fit(X_scaled) inertia_list.append(km.inertia_) silhouette_list.append(silhouette_score(X_scaled, km.labels_)) # 快速找肘部:计算相邻K的inertia下降率 drops = [inertia_list[i] - inertia_list[i+1] for i in range(len(inertia_list)-1)] max_drop_idx = drops.index(max(drops)) + 1 print('建议K值(肘部):', K_range[max_drop_idx]) print('轮廓系数最高K:', K_range[silhouette_list.index(max(silhouette_list))])说明:两个指标经常不一致,肘部看的是“再增加簇数收益锐减”的位置,轮廓系数看的是“簇内紧密、簇间分离”的程度。我的实际处理是:先取肘部位置的 K,再看该 K 的轮廓系数是否大于 0.25。小于 0.25 说明数据本身就没有明显的簇结构,这时我会回到特征工程找问题,而不是硬调 K。
3.3 聚类不是一次性的:训练产物落盘与后续复用
聚类结果必须在训练后落盘,推荐接口不能每次都重新跑 K-means,一是耗时,二是每次结果不一致会导致用户今天看到的推荐明天就变。我习惯把三个产物存下来:标准化器、K-means 模型、用户簇标签表。前两个用joblib存,簇标签直接写进 Django 模型字段。
# 存模型 + 标准化器 import joblib joblib.dump(kmeans, 'models/kmeans.pkl') joblib.dump(scaler, 'models/scaler.pkl') # cluster字段需要提前加到User模型 # 这里用ORM批量写入 from apps.recommend.models import User cluster_map = df['cluster'].to_dict() # user_id -> label for uid, label in cluster_map.items(): User.objects.filter(pk=uid).update(cluster=label)注意:cluster字段我建议直接加在 User 表上,虽然有点“非规范化”,但推荐接口读起来就是一条主键查询,比关联一张簇表快一个量级。如果你要保留每次聚类的历史结果做分析,再单独建一张UserClusterHistory表存快照,当前活跃簇放 User 表。
4. 把聚类结果变成推荐列表:推荐逻辑、Django 视图与冷启动兜底
4.1 推荐核心逻辑:先簇内 TopN,再簇间交叉
聚类只是中间产物,用户打开页面时看到的是推荐列表。我的推荐策略分两层:第一层是“簇内推荐”,取该用户所在簇内评分最高的 N 个菜;第二层是“簇间交叉推荐”,从相邻簇里挑几个口碑菜做补充。为什么加第二层?因为纯簇内推荐会让用户永远只看到自己熟悉的口味,时间长了系统没有惊喜度,转化反而下降。
# services/recommend.py 推荐核心逻辑 from django.db.models import Avg from apps.recommend.models import Dish, Rating, User def recommend_for_user(user, top_n=10): cluster = user.cluster # 簇内用户的评分聚合 user_ids = User.objects.filter(cluster=cluster).values_list('pk', flat=True) qs = (Rating.objects.filter(user_id__in=user_ids) .values('dish_id') .annotate(avg_score=Avg('score')) .order_by('-avg_score')[:top_n]) base_dish_ids = [item['dish_id'] for item in qs] # 簇间交叉:从相邻簇补充 neighbor_clusters = get_neighbor_clusters(cluster, k=2) supplement_ids = get_top_dishes_by_clusters(neighbor_clusters, top_n=3) final_ids = base_dish_ids + [i for i in supplement_ids if i not in base_dish_ids] return list(Dish.objects.filter(pk__in=final_ids))get_neighbor_clusters的实现很简单:把每个簇的聚类中心算出来,再算当前簇中心和其他簇中心的欧氏距离,取最近的 2 个簇作为交叉来源。这样交叉推荐不是随机的,而是从“口味相邻”的簇里找新鲜感。
4.2 Django 视图与序列化:接口返回给前端什么结构
推荐接口返回的数据结构需要包含菜品基础信息和推荐理由。理由字段很重要,哪怕只是“和你口味相似的人也喜欢”,也能显著提升用户点击率。前端拿这套结构直接渲染卡片,不需要再查表。
# views.py 推荐接口 from rest_framework.decorators import api_view from rest_framework.response import Response @api_view(['GET']) def recommend(request, user_id): user = User.objects.get(pk=user_id) dishes = recommend_for_user(user, top_n=10) data = [{ 'id': d.id, 'name': d.name, 'stall': d.stall, 'category': d.category, 'avg_rating': d.avg_rating, 'reason': '和你口味相似的同学推荐' } for d in dishes] return Response({'code': 0, 'data': data})接口设计上我故意不做认证,只传user_id,这样开发和测试阶段方便直接curl验证。生产环境再接入JWT或 Django session。返回结构里的reason字段可以后期扩展成真正的解释文本,比如“第 2 簇的同学 314 人,其中 68% 喜欢重辣口味”,这就要在菜品标签和簇特征关联表里做文章了。
4.3 冷启动兜底:新用户没有行为数据怎么办
K-means 的硬伤是对新用户完全失效——没有历史行为,没法判断属于哪个簇。我的做法是给新用户设置一个“默认簇”,该簇由全部用户的全局聚类中心代表,推荐全局热门菜。同时做一道关卡:用户前三次下单之后,立即用增量数据把用户划入最近的已有簇中心,而不是重新训练模型。
def assign_new_user(user, recent_ratings): # 把新用户行为转成和训练时一致的特征向量 features = build_user_feature(user, recent_ratings) scaler = joblib.load('models/scaler.pkl') kmeans = joblib.load('models/kmeans.pkl') X = scaler.transform(features.reshape(1, -1)) cluster = kmeans.predict(X)[0] User.objects.filter(pk=user.pk).update(cluster=cluster) return cluster这个方案能保证新用户不需要等批量聚类,也能在几次交互后进入正轨。
5. 避坑手册:K-means 做美食推荐的 5 个踩坑现场
5.1 现象:聚类结果全是按“点餐次数”分的,口味完全没体现
原因:特征矩阵里评分和下单次数量纲差异过大,K-means 的欧氏距离被次数维度主导。我见过某同学的项目里,聚类出来的“第 1 簇”全是每周点十次外卖的人,“第 2 簇”全是点两次的人,和口味毫无关系。
解决:必须标准化。StandardScaler标准化的不只是数据分布的收敛,更重要的是让两个维度在距离计算中地位平等。还可以把下单次数做log1p变换再标准化,防止极端值把中心点拉飞。
5.2 现象:每次跑出来的簇都不一样,上周叫“重辣组”这周变成“主食组”
原因:K-means 依赖随机初始化,n_init参数过小或没有固定random_state。校园美食数据量不算大,初始中心选的不同,收敛结果天差地别。
解决:固定random_state=42,n_init=10。如果加了这两个参数结果还不稳定,说明 K 值本身不合适或者特征区分度太低。这时候不要调参,回去加特征。
5.3 现象:新菜品永远进不了推荐列表,聚类模型压根没见过它
原因:K-means 模型训练时菜品维度已经固定,新菜的评分列在原始矩阵里不存在,模型在推理时无法给它任何权重。这算是模型的“黑匣子”效应带来的结构性问题,不是调参能解的。
解决:在训练周期(我推荐每周一次)里重新生成特征矩阵并重训模型。同时单独维护一张“新品榜”,聚类推荐里空出 10%-20% 的位置给新品,避免模型永远推荐老菜。
5.4 现象:推荐接口响应时间突增到 2 秒,数据库被 ORDER BY 拖垮
原因:直接在视图里对簇内所有用户关联聚合,数据量大了以后ORDER BY avg_score DESC没有索引可用。校园数据几千用户还能顶住,上万就开始明显变慢。
解决:离线把每个簇的菜品 TopN 缓存起来,推荐接口直接读缓存。用 Django 的cache框架,簇-菜品列表的缓存键设为cluster_top_{cluster_id},过期时间设为 6 小时,定时任务负责刷新。缓存没命中时才执行重聚合查询。
from django.core.cache import cache def get_cluster_top(cluster_id, top_n=10): cache_key = f'cluster_top_{cluster_id}' result = cache.get(cache_key) if result is not None: return result # 执行聚合查询并写入缓存,有效期6小时 dish_ids = aggregate_cluster_top(cluster_id, top_n) cache.set(cache_key, dish_ids, timeout=6 * 60 * 60) return dish_ids5.5 现象:离线评估准确率很高,用户反馈却没变化,甚至不点
原因:K-means 推荐的准确率衡量的是“簇内用户对推荐菜品的真实评分”,但用户打开页面时看到的卡片、价格、档口距离都影响点击。准确率再高,推一个 1 公里外的档口菜,用户也不会去。
解决:在推荐列表排序阶段叠加“档口距离”和“营业状态”权重,这是工程侧问题,不是算法侧问题。具体做法:先用 K-means 粗筛出候选集(约 30 个菜),再用加权打分公式排序,比如:
final_score = avg_rating * 0.5 + nearby_score * 0.3 + match_cluster * 0.2nearby_score根据用户当前所在宿舍楼到档口的距离计算,match_cluster表示候选菜是否来自用户所在簇,是则给 1,否则给 0.5。这一步调完,点击率通常会有肉眼可见的提升。
6. 进阶技巧:把 K-means 做成每周自动重训的 Django 命令
项目跑稳之后,另一件值得做的事是让聚类自动更新。校园美食的消费偏好不是一成不变的:毕业季旧用户离开,开学季新用户涌入,夏天和冬天的口味也有变化。做成定时命令的好处是不用人肉触发,推荐系统每周自己“复习”一次。
# management/commands/retrain_cluster.py from django.core.management.base import BaseCommand from apps.recommend.services import run_cluster_pipeline class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--k', type=int, default=5, help='聚类数量') parser.add_argument('--min-ratings', type=int, default=10, help='行为少于该值的用户不参与聚类') def handle(self, *args, **options): run_cluster_pipeline( k=options['k'], min_ratings=options['min_ratings'], save_model=True, update_user_cluster=True ) self.stdout.write('cluster retrained and cached refreshed')配合系统定时任务或 CI 定时器,每周执行一次。执行后记得清掉 4.1 节里的cluster_top_*缓存,否则旧缓存会继续给用户推上一个周期的菜。我踩过一次,改完模型忘了清缓存,线上显示的推荐列表还是两周前的,别提多尴尬。
最后一个习惯是:每次重训后把模型效果快照存一份,和上一轮的聚类结果做对比。比如把每个簇的用户数、平均评分、覆盖菜品数记到一张历史表里,跑三个月后你能直接看出:哪段时间推荐质量在下降,是不是该加特征、改 K 值了。这个习惯帮我避免了很多次“为什么效果变差”的盲目排查,也推荐给你。希望帮到你。
本文还有配套的精品资源,点击获取