资讯动态

K-means聚类在校园美食推荐中的实战:特征工程与完整链路解析

发布时间:2026/10/6 5:17:50 来源:尧图企业网站定制
1. 为什么校园美食推荐我首选K-means而不是协同过滤先说一下这个项目的来龙去脉。去年帮学校信息化部门做一个智慧食堂的试点需求很简单学生打开小程序不知道今天中午吃啥系统推荐几个窗口和菜品。就这么个需求我一开始也想过上协同过滤或者深度学习排序模型但真正把校园场景的数据翻出来一看我改了主意最后选了K-means聚类做核心算法。整套系统用Python实现加上前后端展示跑通之后效果居然出奇地好这里把整个过程和经验完整记录下来。先说结论K-means做推荐本质不是聚类即推荐而是聚类圈人/圈菜再在圈内做推荐。这句话是理解整个项目的关键。K-means本身是无监督聚类算法它不直接预测用户会不会点某个菜而是把用户或菜品按特征分成若干个簇让相似的人/相似的菜聚在一起然后在簇内做筛选、排序产出推荐列表。校园美食场景有几个非常特殊的地方恰好让K-means有了用武之地用户群体高度同质主要是学生和教职工年龄、消费能力、口味偏好相对集中聚类出来的簇有明显业务含义。菜品数量可控一个校园食堂一般几十到几百个菜品/窗口远小于电商平台动辄千万级的SKUK-means的计算成本完全可控。数据稀疏严重大多数学生一个月就点那么几十次餐评分矩阵很稀疏协同过滤很容易因为共现矩阵太稀而失效但K-means对稀疏特征的容忍度要好得多。冷启动友好新用户没有历史行为也能推荐——只需要按他的基础特征口味偏好、价格带、宿舍区位找到最近的簇中心隶属的簇这个思路在工程上非常简单。我整理了一个算法对比表格方便后来人做技术选型算法核心思想在校园美食场景的优势在校园美食场景的劣势K-means聚类按特征分簇簇内推荐冷启动友好、可解释性强、训练快、资源要求低特征工程质量直接决定效果对特征缩放敏感协同过滤UserCF/ItemCF找相似用户/相似物品有历史行为时推荐个性化强数据稀疏时效果崩塌新用户无推荐基于内容的推荐计算物品相似度或匹配用户偏好标签可解释性强不依赖用户行为推荐多样性差容易一直推同类菜Apriori关联规则挖掘购买了A也常买B的频繁项集适合套餐搭配推荐数据量小或组合少时规则稀疏当然K-means不是万能的。如果菜品种类非常少比如就十来个窗口聚类纯属多此一举如果用户口味特征差异极大且行为数据足够稠密协同过滤可能更精准。我这个项目选择K-means是充分权衡了数据规模、开发周期、可解释性和部署成本后的结果这一点想清楚很重要。2. 数据从哪来、怎么洗、特征怎么造先于算法的关键工程很多人拿到推荐系统项目就急着调包跑KMeans结果一团糟。我在这里必须强调K-means对输入特征极其敏感特征工程做不好聚类结果就是一坨按编号硬分的随机簇。这个项目我在数据上花的时间占到了总开发时间的六成。2.1 数据源与字段设计校园美食推荐系统的数据来源一般是三块食堂POS机/校园卡消费记录用户ID、食堂窗口、菜品ID、消费金额、消费时间菜品基础档案菜名、菜系、窗口名称、价格、辣度、是否清真、是否有忌口标注用户基础信息年级、性别、常驻宿舍楼栋、注册时填写的口味偏好实际开发中如果你没有真实数据前期可以自己构造一份模拟数据来验证链路字段要严格贴近真实场景。我的数据表设计大概是这样的数据表关键字段说明user_profileuser_id, grade, gender, dorm_building, taste_pref用户画像表taste_pref可以存JSON比如{辣度:3,甜度:2}dish_infodish_id, dish_name, window, cuisine, price, spicy, sweet, salty, oily, avg_time菜品属性表口味维度建议用1-5分order_logorder_id, user_id, dish_id, order_time, amount, rating消费行为表rating是用户事后打分前期可以为空2.2 口味维度怎么量化这是整个项目最核心的地方。K-means是基于距离的算法你必须把口味变成可以算距离的数值向量。我给每个菜品打了5个口味维度分辣度1不辣 - 5特辣甜度咸度油腻度鲜度打分来源可以有三条路径后厨师傅自评、运营人员打标、用户众包投票。三条路径交叉验证取均值能有效减少单一个体的主观偏差。除了口味我还加了三个结构性特征价格带10元以下、10-20元、20元以上映射为1/2/3、出餐速度等级快餐10分钟内、标准餐10-20分钟、慢餐20分钟以上、窗口区位编号1-10。区位很重要因为学生点餐大多有就近原则宿舍到窗口的距离直接影响选择。2.3 特征标准化的必要性这一步很多人忽略但我可以负责任地说不标准化直接跑KMeans数值大的特征会完全主导聚类结果。比如价格如果是整数15元而出餐速度是2两个特征之间的绝对差差了7倍左右。距离计算时价格特征的贡献会被无限放大聚类结果基本就变成了按价格分堆口味特征完全失效。我采用的策略是StandardScaler标准化from sklearn.preprocessing import StandardScaler feature_cols [spicy, sweet, salty, oily, fresh, price_level, speed_level, location_id] scaler StandardScaler() dish_features_scaled scaler.fit_transform(dish_info[feature_cols])标准化之后所有特征的均值为0、方差为1这样距离计算时各个维度才公平发言。2.4 类别特征的编码处理菜系川菜、粤菜、西式快餐、面食、粥粉饭……属于类别特征不能直接塞进KMeans。直接映射成数字川菜1、粤菜2是错误做法因为K-means会认为2和1的距离是1粤菜更接近川菜逻辑上完全说不通。正确做法是One-Hot编码。比如8个菜系就生成8个0/1列。但要注意维度爆炸问题——如果类别太多one-hot后特征矩阵会非常稀疏聚类效果反而下降。校园食堂一般菜系不超过10个one-hot完全没问题。如果将来扩展到上千种菜品的餐饮平台那就得考虑用embedding降维了不过那是另一个层面的故事。3. K-means聚类细节K值、初始化、迭代收敛的三重关卡数据准备好之后核心代码其实很薄但有几个参数和判断逻辑值得单独拿出来讲透。3.1 聚类对象的选择对菜聚类还是对人聚类这个项目我尝试了两条路——对菜品聚类把菜品按口味和属性分簇簇内互为替代推荐和对用户聚类把口味偏好相似的用户聚在一起,簇内互相借用消费记录。两条路各有各的推荐逻辑我最后用的是双路结合先做菜品聚类生成候选池再做用户聚类修正排序权重。这是项目里比较得意的一个设计下面第4章细讲。3.2 K值怎么选手肘图轮廓系数双确认K值是K-means最重要的超参数没有之一。我强烈建议不要拍脑袋定K值用两个指标配合来判断from sklearn.cluster import KMeans import matplotlib.pyplot as plt from sklearn.metrics import silhouette_score # 手肘法看SSE簇内误差平方和下降趋势 sse [] sil_scores [] K_range range(2, 12) for k in K_range: km KMeans(n_clustersk, initk-means, n_init10, random_state42) km.fit(dish_features_scaled) sse.append(km.inertia_) sil_scores.append(silhouette_score(dish_features_scaled, km.labels_)) # 绘制手肘图 plt.plot(K_range, sse, markero) plt.xlabel(K) plt.ylabel(SSE) plt.title(Elbow Method) plt.show()手肘图的判断逻辑是随着K增大SSE会下降但下降速度在某一点后明显变缓这个拐点就是比较理想的K值。注意如果曲线平滑没有明显拐点那说明特征本身没有明显的簇结构强行聚类没有意义。这个时候要回头检查特征工程而不是硬着头皮选K。轮廓系数的取值范围是[-1, 1]值越大说明聚类越紧凑、簇间分离度越好。我一般两个指标结合看先用手肘法圈定一个大致的K值范围再在范围内选轮廓系数最高的K。我在这个项目里最终定K6对应6个口味偏好簇重辣重油型、清淡养生型、甜口爱好者型、面食碳水型、价格敏感快餐型、特色窗口尝鲜型。每个簇的业务含义都非常清晰这就说明聚类结果是有意义的。3.3 k-means初始化与收敛稳定性K-means的初始中心点选择直接影响最终结果朴素的随机初始化可能落到局部最优解。sklearn里的initk-means是默认建议它通过让初始中心尽可能分散来选择种子点大幅提升聚类质量。这一点我在学习和对比过程中专门用不同初始化方式跑过多次对比k-means的簇间分离度明显更稳定。另外两个参数要盯紧n_init默认是10意思是用10组不同的初始中心跑10次取SSE最小的结果。数据量不大时可以适当加大到20更稳。random_state这个务必备注固定值比如random_state42。不固定的话每次运行聚类结果都不同后续生成推荐列表的随机性会非常讨厌你甚至没法复现上一个版本的推荐效果。3.4 MiniBatchKMeans应对数据量增长如果校园卡消费数据积累到百万级菜品特征倒还好因为菜品数量有限但用户聚类的数据量会不断膨胀。此时可以无缝切换到MiniBatchKMeans它每次只从数据集中随机抽取一个小批量样本来更新簇中心计算速度快一个数量级效果损失通常很小。我在后期数据量翻倍后实测MiniBatch耗时从4秒降到不足1秒簇间结构基本没变。部署时有两个选择一个字节内的数据用普通KMeans就行数据量预计会持续增长到几十万行以上再换MiniBatch。4. 从聚类结果到真正的推荐召回、过滤、排序的完整链路聚类只是第一步它产出一堆簇标签离用户真正看到的今天推荐三个菜还有一大截工程路要走。这一步我分成了召回、过滤、排序三段式流程。4.1 召回双路候选集生成路径一菜品簇内召回。当用户进入推荐页先根据用户的画像向量口味偏好、价格带偏好、区位偏好计算它与6个菜品簇中心的距离归属到距离最近的簇然后把该簇内的菜品全部作为候选集。这个路径的逻辑是这个簇里的菜口味风格相近既然你喜欢簇里的A那你大概率也接受簇里的B/C/D。路径二用户簇协同召回。在用户聚类结果里找到与当前用户口味画像最相似的簇这里我用的是Pearson相关系数判断相似度——是的虽然聚类是用欧氏距离但算相似度换用相关系数更能捕捉用户口味曲线的形状相似。然后把簇内活跃用户历史消费次数超过10次高频点过、但当前用户没点过的菜品加进候选集。这个路径相当于一种聚类版的UserCF。两个候选集合做去重合并召回阶段会得到20-30个候选菜。4.2 过滤让推荐可用而非好看召回结果如果不做过滤会出现很可笑的情况——比如下午4点给学生推荐早餐档的粥或者给不吃猪肉的同学推荐卤肉饭。过滤规则我总结了几条硬规则营业时段过滤只在当前营业窗口内的菜品才进入排序。过敏源与忌口过滤利用菜品档案里的配料标签匹配用户设置的忌口。重复消费过滤用户最近7天在同一窗口点过3次以上的菜暂时不再推荐——这是为了多样性和新鲜感。库存/售罄过滤如果食堂接入了实时库存卖完的菜直接剔除。我们试点时没接POS实时库存但这个接口是预留的。4.3 排序组合评分公式过滤后如果候选还是很多需要一个排序公式把最值得推荐的顶到最前面。我用的排序分是几个分数的加权组合final_score 0.4 * cluster_similarity_score 0.3 * dish_hot_score 0.2 * user_history_affinity_score 0.1 * nutrition_balance_score每个分数怎么算cluster_similarity_score当前菜品归属的菜品簇与用户画像向量之间的余弦相似度范围0-1。这个分数越高说明菜品越贴合用户口味偏好。dish_hot_score菜品在全校园范围内的近30天销量排名归一化值。反映大众口碑。user_history_affinity_score如果用户对同一窗口或同菜系有过正向反馈rating大于等于4给该窗口/菜系下的菜品加一个基础分。nutrition_balance_score基于最近一周用户点餐记录做一个简单营养均衡提示——如果连续点了很多重油菜品就稍微上调清淡菜品的排序分。这个分数不需要很复杂规则简单可解释就行。取排序分最高的5个菜推给用户最终展示弹药充足、逻辑闭环。4.4 前端展示架构Flask ECharts推荐算法后端我用Python的Flask做了个轻量API负责接收用户ID、调用推荐链路、返回菜品列表。前端展示用ECharts画了三个可视化面板用户口味雷达图展示用户画像的5个口味维度菜品簇分布散点图用PCA降维到2维把6个簇用不同颜色标出来当前用户所在簇高亮今日推荐卡片列表Top5菜品推荐理由文案比如这道水煮鱼片的辣度和你平时偏好接近这样无论是给学生使用还是给食堂运营方做分析都有直观的界面。整个系统结构不复杂但胜在链路完整。4.5 冷启动用户的处理窍门新注册用户没有任何订单日志怎么推荐我的做法是注册时让他选3个常点菜作为种子行为——比如我经常点麻辣香锅我经常点兰州拉面。把这3个种子菜映射到它们所属的菜品簇取交集或众数得到初始簇归属然后走正常的召回排序链路。实测这个方案的推荐接受率比随机推荐高出一截而且实现成本几乎为零不用机器学习模型也能做冷启动推荐系统场景里值得借鉴。5. 推荐效果怎么量化离线指标和真实场景的差距很多项目跑完聚类就算交差了但我坚持要量化效果否则你根本不知道系统到底行不行也没法和领导/导师交代。我分了两层指标来看这个问题。5.1 聚类质量的离线检查SSE簇内误差平方和看整体紧凑度越小说明簇内样本越相似。选K时已经用到。轮廓系数上文提到最终我的6簇模型轮廓系数是0.41属于结构合理但仍有重叠的区间。对美食推荐这种本身就模糊的偏好场景这个值可以接受不必追求0.7以上的完美分离。簇内业务一致性这个离线指标是通用的聚类评估没有的但对推荐系统特别重要——人工检查每个簇是否能用一句话归纳出这群菜共同的特点。如果某个簇连运营人员都看不出共同点说明特征或K值有问题。5.2 推荐效果的线上评估离线指标再好用户不买账也是白搭。我在试点期间埋了几个点指标计算方式我的观察值推荐点击率用户点击推荐菜品的次数 / 推荐曝光次数约23%显著高于首页首推位默认展示的默认菜品点击率约8%推荐采纳率用户最终点了推荐菜品 / 所有订单数约31%说明推荐有实际引导作用好评率对比通过推荐入口下单的订单好评率 vs 全部订单好评率推荐订单好评率高出约11个百分点这三个指标直接量化了系统的价值。再补充一个细节推荐系统的效果评估一定是基于业务目标的闭环不是算法自嗨。比如食堂的阶段性目标可能是提高某个新开窗口的曝光那我就在排序公式里临时加大dish_hot_score的多样性惩罚强行把新窗口的菜顶上来。这种业务规则挂载在整个推荐框架之上随时可调。5.3 覆盖率与多样性检查一个容易被忽视的指标是推荐覆盖率——系统总共就60道菜如果推荐结果老是集中在最热门的10道那用户很快就腻了。我加了覆盖率报表每周检查一次低于阈值就强制采样部分非热门菜进入候选集。这个机制保证了推荐系统长期使用的新鲜感。6. 踩过的坑和调优实录五个真实教训这个项目让我收获最大的不是算法本身而是踩过的那些坑。我尽量把排查过程写完整方便后来者直接避雷。6.1 坑一类别特征直接转数值聚类结果毫无业务意义最开始偷懒把菜系直接用数字编码川菜1、湘菜2、西式快餐3……跑出来的结果非常莫名其妙一个簇里既有麻辣香锅也有水果捞。排查了很久才发现问题是数字编码的伪距离——粤菜2和烤冷面3之间的欧氏距离被算成了1而川菜1和粤菜2的距离也是1结果完全丧失区分度。排查思路先人工校验聚类簇的业务一致性发现一个簇内菜品口味维度分差异很大于是反推特征入口。把特征工程列出来逐一审查时发现菜系是类别特征却用了数值编码。修复方法就是改用pd.get_dummies()做one-hot编码重跑聚类簇的业务含义立刻清晰了。6.2 坑二评分稀疏导致用户聚类出现空簇用户聚类初期效果很差有一两个簇始终只有几个人。问题出在评分特征太稀疏——大部分用户根本没打过几次分评分列大量是0导致这列特征在距离计算里几乎不发挥作用而真正有区分度的口味维度被稀释了。修复方案一是把评分从连续值打分改成最近30天次均消费金额和口味偏好标签的众数来替代二是对确实没有评分的新用户直接跳过用户聚类只走菜品聚类推荐。这个调整之后簇的用户分布从极端不均变成相对均衡每个簇都有足够的样本数去统计高频菜品。6.3 坑三手肘图没有明显拐点一开始构造的特征全部塞进去画手肘图时SSE曲线光滑得像滑梯根本没有拐点。我一度怀疑是数据量不够。后来才明白特征维度太高且大量无区分度维度进入计算噪声会掩盖真正的簇结构。比如窗口区位编号这种特征对不同宿舍楼的用户意义完全不同直接塞进用户聚类就是干扰项。经验总结特征不是越多越好。先用相关性矩阵和方差阈值筛掉低方差特征再跑聚类。我最终用户聚类只用5个口味维度价格带常去窗口数量7维特征下手肘图拐点明显清晰很多。6.4 坑四KMeans运行时间随数据量暴增试点初期订单表只有几万条跑一次聚类秒级完成。随着数据积累到几十万条普通KMeans的迭代开始慢到让人烦躁。排查后发现是n_init默认重复10次每次都要全量迭代几十轮。调优过程先降n_init到2做临时验证确认效果差异不大后直接切换MiniBatchKMeans。实测后精度损失可以接受但训练耗时下降了一个数量级。这里我想提醒大家不要盲目调参降精度切换适合大数据量的算法变体才是正路。6.5 坑五聚类随机性导致推荐结果每周变一次上线第二周出了问题——没有任何数据变更推荐结果却和第一周完全不同。排查发现是定时任务脚本每次重跑聚类时没有固定种子random_state默认None导致每次初始化中心都不同。修复方法简单粗暴在代码里显式固定random_state42并把训练好的模型保存为.pkl文件下一次跑任务前先加载旧模型做增量更新时间窗内新增数据而不是全量重训。这个坑很多人会遇到排查思路是先确认数据源有没有变化数据没变再看代码里有没有随机性相关的函数发现KMeans没固定种子问题定位就非常快了。7. 写在最后的一点个人体会整个项目从调研、开发到试运行我最大的感受是推荐系统难点不在于算法本身有多高级而在于你能不能把业务问题翻译成特征工程和评价指标。K-means这种老掉牙的算法在合适场景下依然非常能打关键是搞清楚它适合什么、不适合什么然后围绕它的局限性把前后链路做扎实。校园美食这个场景如果将来想继续扩展可以往这几个方向走接入实时POS数据做动态库存感知的推荐、加入季节性调整夏天多推凉菜、冬天多推热汤、甚至用BERT做个菜品评论情感分析来回填口味维度分。技术上都是从现在的框架上长出来的不会推翻重来。如果你手头也在做类似的中小型推荐系统项目我最后的建议就一句话先花大力气把数据特征和评价体系打磨明白再回头调整算法参数效果会好到你不敢信。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑