☰
基于K-Means与Django的校园美食推荐系统设计与实践
2026/10/22 9:25:40 网站建设 项目流程

1. 项目概述:为什么我会想做这样一个校园美食推荐系统

先说说这个项目的起因。我们学校食堂窗口多得离谱,光是麻辣烫就有三家,黄焖鸡米饭五家,更别提那些藏在犄角旮旯的宝藏小铺。但真正的问题是:学生每天中午站在食堂门口,看着乌泱泱的人流和花花绿绿的招牌,反而不知道该吃什么。我观察了两个月,发现大部分人选餐逻辑要么是“随缘”,要么是“跟着人多的队伍排”,真正基于自己口味偏好做决策的少之又少。

市面上其实不缺美食推荐App,美团、大众点评、小红书上都有一堆种草笔记。但校园场景跟校外消费场景有个本质区别:校园食堂的窗口相对固定,菜品更新慢,学生活动范围基本圈定在几栋宿舍楼和教学楼之间,真正能让推荐系统发挥价值的数据维度——比如“你宿舍离哪个食堂步行5分钟”“你上周三吃的什么”“你最近是不是吃腻了辣”——这些数据外卖平台根本拿不到。所以我想做一套专门跑在校园局域网里的美食推荐系统,用Django搭后端,用K-Means算法做口味聚类,让学生用学号登录进去,就能看到一份“懂你”的个性化推荐菜单。

这个项目选K-Means而不是协同过滤,是经过考虑的。协同过滤需要先积累大量用户评分数据才能跑起来,属于典型的“冷启动就瘫痪”。而K-Means这玩意的优势在于:它不需要预先知道菜品的标签类别,只需要把每个用户在各窗口的行为特征向量化,就能自动把口味相似的人聚到一块,再基于同一簇内的高频选择做推荐。换句话说,它适合这种“评分数据稀疏、但行为数据丰富”的校园场景。

整套系统的适用范围其实挺明确:面向高校食堂管理人员做菜品分析也行,面向学生做个人口味推荐也行,甚至可以用来做食堂档口的热度预警——某个窗口连续三周处于低热度簇,管理人员就该考虑调整菜单了。本文会把从数据采集、K-Means聚类、Django接口设计到最终部署的完整链路拆开讲,你如果有类似需求,可以照着这个思路直接抄作业。

2. 整体设计思路和方案选型

2.1 为什么是Django + K-Means,而不是Flask + 协同过滤

技术上我第一反应确实想过Flask,轻量、灵活、上手快。但后来一琢磨,校园美食推荐这种事,未来一定会往外扩功能——比如加个菜品评价后台、食堂公告推送、甚至管理员的档口销量统计报表。Flask虽然都能做,但很多东西得自己拼:ORM要自己配,后台管理要自己写,用户认证要自己搭。Django把这些东西全都内置了,admin后台、auth认证、ORM、模板引擎开箱即用,省掉的开发时间不止一星半点。

更重要的是,Django的ORM对于“用户—订单—菜品—口味标签”这种多表关联场景,用起来太舒服了。你不需要手写一大堆SQL JOIN语句,直接用模型关系就能把数据查出来。而且Django自带的admin站点,可以让食堂管理员直接可视化看到每个窗口的评价数据和推荐统计,这部分几乎零成本。

算法层面,用K-Means而不是层次聚类或DBSCAN,也是从工程角度权衡过的。层次聚类不适合大样本量,食堂如果有一万学生用户,那个距离矩阵就能把内存顶爆;DBSCAN虽然能处理任意形状的簇,但参数调起来太玄学,ε和MinPts稍微设置不当结果就差得离谱。K-Means实现简单、收敛快、可解释性强,拿来做口味聚类绰绰有余,而且主要计算可以在Django的后台任务里跑,不阻塞主业务流程。

2.2 系统的总体架构分层

我把系统分成了四层,从下往上分别是:

  • 数据采集层:从校园卡消费流水和菜品订单表里清洗出用户行为数据
  • 算法服务层:负责特征向量化、K-Means聚类、簇内推荐逻辑
  • 业务应用层:Django处理用户登录、菜品展示、推荐接口、收藏点赞
  • 展示层:前端页面(PC和手机浏览器自适应)

数据流转是这样的:校园卡消费记录每天凌晨同步一次到本地MySQL数据库,经过清洗后存入用户行为表;推荐系统定时任务每两天跑一次K-Means聚类,更新用户的簇标签;用户当天访问系统时,直接读取所在簇的群体偏好,结合个人历史点餐记录生成推荐排名。

这个架构的好处是:聚类计算不占用用户请求的时间,用户拿到推荐结果基本是毫秒级响应。数据采集和推荐计算解耦,哪怕K-Means任务挂了,用户至少还能正常登录、查看历史记录,不至于整个系统全瘫痪。

2.3 系统核心流程图(非专业图表版)

我用文字描述一下核心数据流转,方便理解:

校园卡消费流水(MySQL) → 数据清洗脚本 → 用户行为特征表 → K-Means聚类脚本 → 用户-簇映射关系 → Django推荐视图函数 → 模板渲染 → 学生端页面

推荐视图函数做的事情很简单:拿到当前登录用户的簇ID → 在订单表里查询同簇用户近7天点过的高频菜品 → 过滤掉当前用户已经点过超过3次的菜 → 按热度排序返回。

3. 数据采集与处理:推荐系统的燃料从哪来

3.1 两条关键数据来源

做推荐系统,最怕的不是算法选错,而是数据根本不够或者质量太烂。我这边数据来源主要有两块:

第一块是校园卡消费流水。校园卡充值、消费都有记录,每条流水的字段大致是:卡号、交易时间、消费金额、商户名称、消费窗口。这块数据胜在量大、真实,食堂每次刷卡都会产生一条记录,一个月下来十几万条是有的。但问题也很明显:流水里只有商户名称,没有具体菜品的名称。比如你在“铁板厨房”窗口刷卡15元,流水里只会记下“铁板厨房”,不会记下你具体点的是黑椒牛柳饭还是咖喱鸡肉饭。

第二块是针对菜品层面的点餐数据。这个得从食堂窗口的POS机或者点餐小程序里拿,字段包括:订单号、用户ID、菜品名称、分类、价格、下单时间。这个数据才是推荐系统的核心燃料,因为有具体的菜品粒度。但麻烦在于,校园里部分窗口可能还没有数字化点餐,靠人工记忆或者纸质菜单,这部分就补不齐。

我采用的方案是“两个数据源合并建模”:以点餐数据为菜品粒度主数据,以校园卡流水作为用户活跃度和口味偏好的辅助验证。比如某窗口这个月的流水比上个月下降40%,但点餐数据里它的黄焖鸡米饭评分反而上涨了,那就说明窗口可能存在出餐速度问题,需要管理侧介入。

3.2 特征向量怎么设计才合理

用户特征向量是K-Means的直接输入,它长什么样直接决定聚类效果。我设计了以下几个维度的特征,每个维度都做了归一化处理:

特征维度具体含义归一化方式
辣度偏好用户在菜品标签为“辣/微辣/特辣”窗口的消费频次除以总消费频次
甜度偏好用户在奶茶、甜品窗口的消费占比除以总消费频次
面食偏好面类、粉类窗口消费占比除以总消费频次
米饭偏好米饭类套餐窗口消费占比除以总消费频次
夜宵活跃度20点之后的消费次数除以总消费次数
消费频次每月的总消费次数Min-Max归一化到0~1
单次平均消费额平均每笔消费金额Min-Max归一化到0~1

单个用户的特征向量就是这样一条:

[0.33, 0.02, 0.11, 0.54, 0.18, 0.72, 0.45]

前面五个维度是偏好占比,最后两个是行为活跃度和消费力水平。把这些维度揉进聚类,算法能自动识别出类似“无辣不欢的夜宵党”、“清淡养生食堂常驻族”、“高消费烤肉爱好者”这样的隐形用户群。

这里有个我踩过的坑:千万不能直接对原始消费次数做聚类。因为饭量大的学生一个月刷50次,饭量小的可能只刷10次,数值相差5倍,K-Means的欧氏距离会被这个维度带偏,导致聚类完全按消费频次划分,跟口味毫不相关。所以我严格控制在“占比 + Min-Max归一化”这一套,确保每个维度都处在同一个量级。

3.3 数据清洗注意事项

数据清洗这块看着不起眼,实际最耗时,我大概整理了三条心得体会:

一是清洗掉测试窗口的数据。食堂偶尔会有免费试吃窗口、临时活动窗口,这些流水如果不剔除,会把用户特征带偏。比如某天试吃窗口搞活动,大量学生去刷了0.01元,如果把这部分计入夜宵活跃度,那就车裂了。

二是处理同一时段多次刷卡的操作。有的窗口按单品分别计费,学生吃一顿饭可能刷两三次卡,如果按“每次刷卡=一次完整用餐”来统计,用户消费频次会被翻倍。我按卡号+30分钟时间窗口合并为一次用餐事件,顺序加和金额,这样才准确。

三是菜品重名和别名问题。同一个菜在不同窗口叫法不一样,比如A窗口的“番茄鸡蛋面”和B窗口的“西红柿蛋面”其实是同一个东西。我维护了一张菜名归一化表,用编辑距离匹配相似菜名,人工抽检确认后替换。这一步不做,聚类时同一个菜会被拆成两个特征,数据稀疏性会加剧。

4. K-Means聚类的原理和工程实现

4.1 算法原理的通俗理解

K-Means本质上就是干一件事:给定一堆点,把它们分成K堆,让每堆内部尽量紧凑、堆与堆之间尽量分开。

具体流程四步走:

  1. 随机定K个中心点
  2. 计算每个用户到K个中心点的距离,归属到最近的中心
  3. 把每个簇内的所有用户点求平均,得到新的中心
  4. 重复第2步和第3步,直到中心点不再移动或达到最大迭代次数

我在学校里给非计算机专业的同学打了个比方:K-Means就像一个班长组织自习分组,先随机挑K个座位作为组中心,大家各自坐到离自己最近的那个座位旁边,然后每组同学挪一挪,把桌子聚到组中心重新排位置,再按新位置继续坐,来回几次之后就稳定了。

这个比喻虽然糙,但抓住了核心——每一轮都在优化“簇内距离最小化”这个目标函数。K-Means收敛速度很快,通常迭代十几轮就能稳定,适合我们这种几十万条数据的场景。

4.2 如何确定K的取值:手肘法实战

K-Means最尴尬的问题就是K怎么定。定少了,聚类太粗,口味差异大的用户被强行捆在一起;定多了,聚出一堆碎片簇,每个簇只有三五个人,推荐跟没推荐一样。

我采用的方法是经典的手肘法(Elbow Method)。具体做法是:对K从2到10跑一遍K-Means,记录每次的簇内误差平方和(SSE),然后画折线图看拐点在哪里。

SSE公式为:SSE = sum(distance²),对每个簇内所有点到簇中心的距离平方求和。

我跑出来的数据大致是这样的:

K值SSE
23850.4
32490.8
41738.2
51320.6
61019.5
7836.3
8770.4
9748.2
10731.5

可以看到,K从2到6下降非常陡峭,但从7开始降幅变得很平缓。曲线在K=6附近出现明显拐点,所以我把K定为6,也就是把全校学生初步分成6种口味社群。这个结果跟我预期比较吻合:辣口、清淡、面食党、烤肉热衷、甜品依赖、均衡型。

关于K值选择,网上的教程一般推荐看SSE肘部,但实际操作我会再加一条验证:结合业务可解释性看质心(簇中心)。如果某几个簇的质心向量高度相似,说明K定大了,得合并;如果某个簇只剩极少数人,说明K可能太大了或者有离群用户。机械地盯着拐点有时候会选出数学上合理但业务上无意义的K值。

4.3 K-Means的Python实现代码

我先用Python把算法逻辑跑通了,用的库是scikit-learn,整体代码量不大:

import pandas as pd import numpy as np from sklearn.cluster import KMeans from sklearn.preprocessing import MinMaxScaler import joblib # 读取清洗后的特征数据 df = pd.read_csv('user_features.csv') user_ids = df['user_id'] feature_cols = ['spicy_ratio', 'sweet_ratio', 'noodle_ratio', 'rice_ratio', 'night_snack_ratio', 'consume_freq_norm', 'avg_amount_norm'] X = df[feature_cols].values # 手肘法确定K值 sse = [] for k in range(2, 11): km = KMeans(n_clusters=k, random_state=42, n_init=10) km.fit(X) sse.append(km.inertia_) # 选择K=6 k = 6 km = KMeans(n_clusters=k, random_state=42, n_init=10) km.fit(X) # 输出每个簇的中心点(用于业务解读) centers = km.cluster_centers_ for i, center in enumerate(centers): print(f"簇 {i}: {center}") # 保存模型和簇标签 df['cluster'] = km.labels_ df.to_csv('user_cluster_result.csv', index=False) joblib.dump(km, 'kmeans_model.pkl')

注意打印出来的每个簇中心向量,一定要拿给懂业务的人看,一起给簇起个“人话名称”。比如某个簇中心长这样:[0.62, 0.03, 0.08, 0.21, 0.45, 0.68, 0.32],那很明显就是“爱吃辣、夜宵多、消费频次高”的群体,直接起名叫“夜宵辣党”。有了簇的业务名称,后面推荐和报表展示就能直接给用户看,不用猜。

我自己写的特征向量和上述方案在部署后跑出来的效果还不错——但和任何项目一样,光靠默认参数是不够的,下面分享一下参数调优与警告事项。

4.4 n_init和random_state这两个参数的坑

这是我在实际调试中踩过的一个非常典型的坑。如果你直接写KMeans(n_clusters=6)就跑,你会发现每次跑出来的结果都不太一样,甚至用户分簇变化很大。原因是K-Means的初始中心点是随机选的,而初始中心点不同,最终收敛到的局部最优解可能就不同。

解决方案就是两个参数必须显式设置:

km = KMeans(n_clusters=6, random_state=42, n_init=10)

random_state固定随机种子,保证可复现——我调参的时候需要对比不同特征组合的效果,如果每次聚类结果都不一样,你根本分不清是特征改得好还是随机性造成的。n_init代表算法会从不同初始点跑10轮,取SSE最小的那轮,这是为了尽量减少随机初始化的影响。

还有一个细节:n_init设成10,跑十万条数据确实会慢一些,但我实测下来也就几十秒,完全在可接受范围内。如果你数据量大到上千万,可以考虑降为n_init=4,或者用Mini-Batch K-Means牺牲一点精度换速度,不过校园场景用不到这么极端。

5. Django后端设计:从模型到接口

5.1 数据库模型设计

我前前后后改了四五版表结构,最终留下的是这几张核心表,还是直接看代码直观:

from django.db import models from django.contrib.auth.models import AbstractUser class UserProfile(AbstractUser): student_id = models.CharField(max_length=20, unique=True) dormitory = models.CharField(max_length=50, blank=True) cluster_id = models.IntegerField(null=True, blank=True) taste_tags = models.CharField(max_length=200, blank=True) class CanteenWindow(models.Model): name = models.CharField(max_length=100) location = models.CharField(max_length=100) cuisine_type = models.CharField(max_length=50) class Dish(models.Model): name = models.CharField(max_length=100) window = models.ForeignKey(CanteenWindow, on_delete=models.CASCADE) category = models.CharField(max_length=50) price = models.DecimalField(max_digits=6, decimal_places=2) spicy_level = models.CharField(max_length=20) monthly_sales = models.IntegerField(default=0) class UserDishOrder(models.Model): user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) dish = models.ForeignKey(Dish, on_delete=models.CASCADE) order_time = models.DateTimeField() amount = models.DecimalField(max_digits=6, decimal_places=2) class DishRating(models.Model): user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) dish = models.ForeignKey(Dish, on_delete=models.CASCADE) rating = models.IntegerField() comment = models.TextField(blank=True)

几个设计细节值得说一下:

第一,用户表的cluster_id字段单独放在用户表上,而不是建一张关联表,是为了查询性能考虑。用户每次请求推荐接口时,直接走一次主键查询就能拿到簇ID,不需要JOIN。虽然这违反了“宁可冗余不可频繁JOIN”的粗浅认知,但在这种读多写少的场景下完全是划算的。

第二,菜品表里我专门加了spicy_level字段。这字段可能看着多余,但其实跟K-Means的辣度特征是对应的。推荐出来的菜如果有“辣度等级”标签,就能让前端按“用户所在簇的辣度偏好”过滤排序,体验更细腻。

第三,UserDishOrder表是核心行为数据表。每天凌晨同步脚本往这张表里灌数据,而不是直接读校园卡流水临时表。好处是查询推荐时不需要天天面对原始流水里那些烂字段,而且这张表索引建好了,按user_id + order_time范围查询特别快。

5.2 核心推荐接口设计

推荐接口的路径设计很简单,就一个视图函数:

from django.shortcuts import render from django.db.models import Count from .models import UserProfile, UserDishOrder, Dish def recommend_view(request): user = request.user cluster_id = user.cluster_id if cluster_id is None: # 冷启动,没有聚类结果时直接返回热门推荐 hot_dishes = Dish.objects.order_by('-monthly_sales')[:10] else: # 先找到同一簇其他用户最近一周的点餐记录 recent_orders = ( UserDishOrder.objects .filter(user__cluster_id=cluster_id) .filter(order_time__gte=timezone.now() - timezone.timedelta(days=7)) .exclude(user=user) .values('dish_id') .annotate(order_count=Count('dish_id')) .order_by('-order_count') ) dish_ids = [r['dish_id'] for r in recent_orders[:20]] # 排除用户自己点过太多次的菜 eaten_too_much = set( UserDishOrder.objects .filter(user=user) .values_list('dish_id', flat=True) ) favorite_dish_ids = [did for did in dish_ids if did not in eaten_too_much][:10] hot_dishes = Dish.objects.filter(id__in=favorite_dish_ids) return render(request, 'recommend.html', {'dishes': hot_dishes})

这里推荐逻辑并不复杂:同簇人点的多 → 你喜欢概率就高,但这个前提是聚类聚得准。所以算法的重心其实不在推荐函数本身,而在前置的K-Means特征工程。

另外,我在逻辑里特意加了一个exclude(user=user),过滤掉用户自己的点餐记录。这是个很重要的细节:如果不过滤,用户自己狂点的菜会出现在推荐列表里,看似没问题,但推荐出来的菜全是“你已经吃腻的东西”,用户体验很差。加上这个过滤之后,推荐才真正有“探索新口味”的意义。

5.3 定时任务跑聚类

聚类脚本不能手动每次跑,得挂到定时任务里。我用的是Django-Celery + Redis做任务调度,每天凌晨两点触发:

# tasks.py from celery import shared_task from apps.recommend.clustering import run_kmeans_pipeline @shared_task def daily_recommendation_update(): run_kmeans_pipeline()

run_kmeans_pipeline做的事情依次是:重新从订单表计算用户特征 → 跑K-Means聚类 → 更新用户表的cluster_id → 清理推荐缓存。整个流程跑下来大概三分钟,完全不影响白天正常业务。

但Celery这套配置起来有点繁琐,如果你是第一次做,我劝你先把定时任务用简单的crontab + manage.py shell -c跑通。我最初就是写了独立Python脚本,然后用Linux crontab定时调用python manage.py runscript kmeans_pipeline,这一步通了再考虑引入Celery做更复杂的异步任务。千万别一上来就堆技术栈,中小型项目里“简陋但稳定”永远胜过“复杂但老是出问题”。

6. 冷启动与推荐效果优化

6.1 新用户没有任何数据怎么办

K-Means依靠用户行为数据做聚类,那新注册的学生呢?他们没有任何点餐记录,特征向量全零,直接扔进聚类会被丢到一个莫名其妙的簇里。所以我单独处理了新用户的冷启动问题,思路是分两步走:

第一步,新用户注册时填写一个三题的“口味偏好问卷”:

  • 你平时口味偏哪个方向?(辣、清淡、甜、随缘)
  • 你最喜欢哪类主食?(米饭、面食、粉类、都不挑)
  • 你晚上有吃夜宵的习惯吗?(经常、偶尔、几乎不)

这三个答案直接构建初始特征向量,比如“辣+面食+经常夜宵”就映射成 [0.5, 0, 1, 0, 0.8, 0.5, 0.3]。虽然粗糙,但至少能初步归入一个簇。

第二步,等新用户积攒了至少20条有效点餐记录后,系统用真实行为特征替代问卷特征,下一次定时聚类时自动归到更精准的簇。

这个策略在推荐效果上很直观:第一天的推荐结果虽然不精准,但方向不会跑偏——爱辣的就推荐辣窗口的招牌菜,不会推一道甜品给他。等数据积累到一定程度,推荐精度就会明显上升。

6.2 推荐结果多样化,不要变成“信息茧房”

纯靠“同簇高频菜”做推荐,最容易出现的问题是推荐列表高度同质化:连续一周推荐的都那几道菜,学生就腻了。我加了三层多样化策略:

一是引入探索因子。推荐列表的前6个放簇内高频菜(这是“利用”),后4个放簇内用户点过但频率不高、同时评分很高的菜(这是“探索”)。探索菜的好处是,即使用户没试过,但如果同口味群体里有人给了高分,大概率也符合他的口味。

二是加入时间季节因子。周五晚上、节假日、天气降温的时候,推荐内容应当偏移。比如周五晚上多推一些聚会型窗口(烧烤、火锅),降温天多推麻辣烫、砂锅这类热菜。这个因子我用的是一个简单的时间权重函数,对候选菜的得分做乘法调整。

三是位置因子。宿舍楼跟食堂的距离很关键。A食堂离3号宿舍楼近,B食堂离7号楼近。如果推荐列表里全是B食堂的菜,住在3号楼的学生大概率懒得跑过去。这个信息存在UserProfile.dormitory字段里,推荐时直接按宿舍到食堂的步行距离做一次反向加权。

这三层策略叠加之后,推荐列表不再是一潭死水,而是每天都有值得期待的“微变化”。

6.3 推荐效果怎么评估

做推荐系统最怕的就是自我感觉良好,没有一套客观评估手段。我用了一个非常朴实但有用的评估方法:次日回头率。

具体做法是:每天早上跑一遍推荐列表,记录每个用户在当天中午实际点餐时,点的菜里有多少是推荐列表里出现的。统计下来,推荐命中率公式为:

命中率 = 当天点餐命中推荐列表的菜品数 / 当天实际点餐菜品总数

我目前跑出来的基线数据是,开通推荐前每天随便选餐的命中率大概只有15%左右(因为食堂窗口多、菜品种类也多),加上推荐系统之后,用户群体的平均命中率能稳定到35%~40%,翻了接近两倍多。虽然跟电商平台动不动70%的精准度没法比,但在校园食堂这种相对封闭的场景里,已经算是能感知到的提升了。

另外每月我会抽查一次聚类质心向量的稳定性。如果连续两个月质心偏移很小,说明学生的口味偏好其实比较稳定,推荐系统不用频繁重训。如果某个月质心突然大幅偏移,八成是食堂上新了一大波新品或者某些窗口做了菜单调整,这时候需要重新采集特征再聚类。

7. 前端交互设计:让学生愿意用才是王道

7.1 页面结构和交互逻辑

后端再好,前端做得烂一样没人用。这个系统的前端页面,我没用什么高深框架,就是Django自带的模板系统 + Bootstrap + 少量jQuery。页面总共四个核心模块:

主页面是“今日推荐”:顶部是开启“今日幸运菜”按钮,点击后系统随机从用户的推荐列表里抽一个;下面依次展示推荐榜前10道菜,每道菜配窗口位置、价格、辣度等级、同簇好评率。

第二页是“我的口味地图”:用饼图展示用户的特征向量占比,比如“辣度偏好35%、面食偏好28%、甜度偏好12%”,让学生直观看到自己原来是个“辣面党”。

第三页是“窗口热力榜”:按簇展示每个窗口的热度排行,学生可以看看自己所在口味簇里最受欢迎的窗口是哪家。

第四页是“历史记录”:回看自己过去30天点过哪些菜,每天吃的什么一目了然。

前端的核心目标是:让学生在10秒内获取“吃什么”的答案,同时不影响他们探索新窗口的冲动。所以我特意把推荐列表的卡片做成了“翻牌”交互,每次刷新会随机调整卡片顺序,避免学生觉得“每天就那几个菜,没意思”。

7.2 推荐列表展示的细节打磨

推荐列表卡片的信息密度控制很重要。我试过一版把“同簇好评率”和“最近7天点单量”全塞进去,结果卡片字太多,学生根本读不完,反而降低了点击率。后来砍到只保留三个核心指标:销量排行、辣度标签、窗口距离。销量暗示群众选择,辣度暗示口味契合度,距离暗示可达性。这三个信息足够支撑一次决策。

还有个小的视觉细节:菜品卡片采用大图+色块设计。辣度用火锅红、清淡用浅绿色、甜口用暖黄色,不同色块建立潜意识联想。学生刷首页时一眼就能从色块里捕捉到“今天该吃辣的还是清淡的”。

7.3 移动端适配没必要大动干戈

本来我纠结要不要做小程序,后来想明白了,直接做手机浏览器自适应页面就够了。原因有三:第一,校园卡系统对接小程序需要额外审核和开发成本;第二,学生对移动网页的容忍度比想象中高,只要别卡、别丑就行;第三,微信里直接打开链接就能用,不占内存不下载App,转化率反而更高。

我用了Bootstrap栅格系统做响应式布局,测试下来iPhone和小米这类主流机型显示都没有问题。顶部导航做成了可折叠菜单,核心操作都在首屏内完成,基本不需要滚动。

8. 什么时候别用推荐系统:边界与失败案例

多句嘴,K-Means校园美食推荐不是什么场景都能套的。我从这个项目里碰到了几个典型的“推荐失灵”场景,提前说出来帮你避坑。

第一,食堂档口数量太少的时候别做。如果全校只有十个窗口,推荐系统的价值几乎为零,因为没有足够的候选集。K-Means再强,也只是把十个窗口翻来覆去地排列组合,学生一眼就能看全所有选择,算法反而显得多余。

第二,菜品高度相似的时候别做。如果食堂所有窗口做的都是同样几道家常菜,用户特征向量会高度相似,聚类结果基本是随机划分。我那个项目里,前两个月食堂有将近三分之一的窗口都在做盖浇饭,聚类出来各簇质心几乎一样,后来加了菜品标签分类(炒菜、汤粉、快餐、甜品、烧烤)才把差异拉出来。

第三,消费行为被“套餐化”主导的时候别做。如果食堂强制推套餐,学生根本没法单点,消费数据就映射不到口味偏好上。比如每餐都是“两荤一素”固定搭配,那聚类出来的更多是价格敏感度的分群,跟美食推荐没关系。

第四,数据质量太差的时候先别急着上算法。我一开始拿到手的消费流水里,用户ID存在不少重复和格式不一致问题,花了两周时间清洗才算勉强能用。如果连“用户—菜品—时间”这个三元组都理不清,任何推荐算法都是空中楼阁。

9. 部署上线与踩过的坑

9.1 生产环境部署方案

Django项目的部署,我选的是经典组合:Nginx + Gunicorn + MySQL + Redis。选这套的原因就一个——稳,资料多,踩坑别踩出一片新天地。

具体部署流程大致如下:

  1. 服务器上装Python 3.10,创建虚拟环境,安装项目依赖。
  2. 配置MySQL数据库,导入表结构和初始数据。
  3. Django中把DEBUG=False,配置好ALLOWED_HOSTS,设置STATIC_ROOT。
  4. 执行python manage.py collectstatic,收集静态文件到服务器磁盘。
  5. 用Gunicorn启动Django应用:
gunicorn campus_food.wsgi:application --bind 0.0.0.0:8000 --workers 3
  1. Nginx配置反向代理,把所有请求转发给Gunicorn,同时由Nginx直接托管静态文件。
server { listen 80; server_name food.example.edu.cn; location /static/ { alias /var/www/campus_food/static/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
  1. 用Systemd管理Gunicorn进程,配置Restart=always,保证进程挂了能自动拉起来。

这套方案最大的优点是排查问题方便。前端静态资源挂了看Nginx日志;后端接口挂了看Gunicorn日志;数据问题直接查MySQL慢查询日志。每一层职责清晰,不用像某些一体化容器方案那样出了问题不知道查哪儿。

9.2 常见部署问题排查实录

部署过程中我遇到的坑,整理成一张速查表,基本覆盖了新人最常碰到的几类问题:

故障表现可能原因排查方法解决方案
首页能打开但静态文件全丢Django没配STATICFILES_DIRS或没执行collectstatic浏览器F12看资源请求返回404执行collectstatic + 检查Nginx静态路径
接口返回500但Django后台日志为空Gunicorn worker进程数不够或内存溢出查看journalctl -u gunicorn调整workers数量或增大服务器内存
推荐列表全为空用户cluster_id还没更新查看数据库clster_id字段是否为空手动触发一次K-Means定时任务
定时任务没触发crontab环境变量没配好查看crontab执行日志用绝对路径执行Python命令
用户登录后报CSRF错误Django的CSRF中间件生效,前端模板没加csrf_token查看表单页面源码在模板form里加{% csrf_token %}
MySQL连接数爆掉没用连接池,瞬时并发高看MySQL连接数统计用django-db-connection-pool或提高max_connections

最惨的一个坑是:聚类脚本凌晨跑的时候正好赶上高峰用餐前缓存清理,导致脚本内存占用过高被系统OOM杀掉,而Django主进程没受影响,但用户的所有推荐结果全都停留在上一次的旧数据上。后来调整了任务调度时间线,放到凌晨3点之后、避开了日志轮转和数据库备份时间段,这个问题才算解决。

9.3 系统运行性能实测

系统上线后,我专门做了一轮性能测试。在500个并发用户同时访问“今日推荐”页面的压力下,Django接口的响应时间中位数约220ms,95%响应时间约420ms,没有出现超时或连接拒绝。考虑到校园实际峰值并发也就是几百人同时在线,这个性能完全够用。

数据库层面,核心查询语句都走了索引,推荐列表的查询耗时可控制在30ms内。K-Means全量聚类接近2万条用户特征数据时,耗时约40秒,加上数据清洗报表生成,整体控制在3分钟内,作为凌晨批任务完全可以接受。

整个系统目前跑了差不多两个学期,期间经历过一次校园食堂全面换菜单、两次新生入学的大规模冷启动,推荐系统都能较快地恢复稳定。对于“K-Means是不是过时了”这个疑问,我的回答是:关键看场景。在校园这种数据规模不算大、特征清晰、用户群相对稳定的环境里,K-Means仍然是一个性价比极高的选择,它不会比深度模型差多少,而成本和可解释性优势却是实打实的。

10. 写在最后:如果再让我重做一次

这个系统从最初的想法到今天能稳定运行,前后大概花了三个多月业余时间。中间走过不少弯路,比如最开始试图爬取外卖评论来补充菜品数据,后来发现爬回来全是“包装漏油”“配送超时”这种跟口味无关的内容,费力不讨好;又比如一开始把Django admin彻底放着没管,后来发现食堂管理员根本不会用后台,又花了整整两周做了一个极简版的操作面板。

如果让我再重做一次,我会在第一天就把“食堂管理员”这个角色拉进需求讨论,而不是等项目做完再回头适配他们的操作习惯。推荐算法做得好不好,到头来只是系统的一个模块;系统能不能被真正用起来,取决于它是否融入了校园那个复杂又真实的生活场景。

最后分享一个我在实际运维中的小技巧:K-Means聚类结果不要永久固化,每学期至少重训两次,分别在开学后第三周和期中之后。因为新生的口味偏好会在开学第一个月剧烈变化——刚脱离高中食堂的学生,面对大学食堂的多样化窗口会经历一段“报复性尝鲜期”,这时候的消费数据完全不能代表稳定口味,提前聚类只会得到一份毫无参考价值的模型。

这个项目后续可以扩展的方向很多,比如把食堂备餐余量数据接进来做“低排队智能推荐”;比如把学生课程表关联起来,推荐“下课顺路、15分钟内能吃完饭”的组合方案;再比如跟食堂管理系统的菜品销量库存联动,帮助窗口提前调整备菜计划。校园场景看似小,但数据闭环一旦打通,能做的事情比想象中多得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询