ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于K-means的校园美食推荐系统实战:数据清洗到Flask部署

基于K-means的校园美食推荐系统实战:数据清洗到Flask部署 一到饭点就成了选择题困难户食堂窗口和外卖列表切换半天也不知道吃什么——这是校园里最常见的吐槽也是我这个项目最直接的出发点。我花了大概两周时间用 Python 做了一个基于 K-means 算法的校园美食推荐系统把同学们对食堂和周边菜品的口味评价整理成结构化数据聚类出几类“口味人群”再让用户按自己的口味画像拿到一份更像样、更对胃口的推荐清单。这篇文章会把整套东西从数据采集、特征工程、算法选型到 Flask 展示完整过一遍顺手把那些坑也一起说清楚。适合正在做大数据/推荐系统毕业设计、或者对聚类算法落地感兴趣的 Python 学习者参考。1. 项目背景与整体设计思路1.1 为什么选 K-means 做校园美食推荐先聊一个很现实的问题市面上主流的推荐算法那么多协同过滤、矩阵分解、深度学习模型都有现成框架为什么我偏偏选了 K-means 这种“老派”聚类算法第一个理由是场景匹配。校园美食推荐和电商推荐不一样它的用户群高度集中就是本校学生和教职工菜品种类相对有限基本就是食堂窗口、校内商铺和校园周边固定那几十家店。这种环境里用户口味天然会形成几类明显的“族群”有的无辣不欢有的清淡养生有的只在意出餐速度。聚类算法本身就是干这个的它不需要复杂的序列行为数据也不需要用户点击日志几份问卷或者评分表就能跑起来。第二个理由是可解释性。K-means 跑完之后你能清楚看到每个簇的中心点代表什么口味倾向比如“重辣重油组”“清淡均衡组”“甜口快餐组”。这种分群结果可以直接展示在系统后台也能跟用户解释“你为什么被分在第三组、为什么推荐这几道菜”。对比起来协同过滤的效果虽然也不错但用户特征向量稀疏时特别容易崩而且冷启动问题严重——新用户没有任何行为记录时协同过滤基本抓瞎。基于内容的推荐则需要给每道菜做大量标签化处理比如菜品食材、做法、口味标签这个标注成本在校园场景里堪称灾难。第三个原因是实现成本。K-means 在 scikit-learn 里就是一行KMeans(...)的事训练时间在几千条数据量级下以毫秒计不需要 GPU不需要搭建分布式环境。整个项目我用一台普通笔记本就能跑完这对课程设计、毕业设计来说非常友好。当然它也有短板比如簇数量 K 要自己定、对离群点敏感、假设簇是凸形分布但这些问题在校园美食这个规模的数据上完全可控代价很低。1.2 系统整体架构与推荐链路这个系统的数据链路可以拆成四层画出来就是一条清晰的生产线数据采集层通过问卷调研、食堂菜单整理和公开数据集拿到菜品基础信息、用户评分和口味偏好。数据预处理层用 pandas 做缺失值处理、去重、文本统一、数值化编码然后把“用户 菜品评分”的明细表聚合为“用户口味特征矩阵”。算法层对用户的六维口味向量做标准化然后跑 K-means 聚类得到口味族群并计算每个簇的偏好菜品集合。应用层用户提交自己的口味问卷或历史评分系统计算其所属簇返回该簇内评分最高的 Top-N 菜品再用 Flask 提供 HTTP 接口配合 ECharts 做可视化展示。推荐链路本身不复杂核心就三步先算用户口味向量再找它离哪个簇中心最近最后从簇内候选菜品里排序输出。听起来简单但真正做起来你会发现最花时间的不是算法而是“怎么把用户对食物的模糊感受变成模型能算的数值”。2. 数据采集与特征工程2.1 菜品与评价数据怎么来数据是推荐系统的口粮这一步的质量直接决定后面模型的上限。我当时用了三种方式组合避免单一来源带来的偏差。首先是校园实地调研。我把学校食堂和周边小店的菜单整理成了结构化表格记录了菜品名称、所属窗口/店铺、价格、类型荤/素/主食/饮品。这部分是菜品池的基础保证推荐结果不会出现“系统推荐了一道食堂根本没卖的菜”这种尴尬情况。然后是口味问卷。我在同学群里发了一份简单的评分问卷让大家按 0-5 分给自己吃过且印象深刻的菜品打分同时收集自己的口味偏好标签比如“能接受多辣”“喜欢吃甜”“赶时间时会选什么”。问卷不能设计得太多太长否则没人愿意填我当时就控制在 10 分钟内能完成的量。这一层得到的是“用户-菜品-评分”的明细数据是后面构建用户口味特征矩阵的原材料。最后是公开数据集补漏。如果只靠问卷覆盖的菜品和用户还是太有限我从一些开源的美食点评数据集里抽取了同类型菜品评分作为补充。这里要提醒一句如果打算用爬虫从点评类网站抓数据一定要先看清楚对方的 robots 协议和服务条款个人学习研究尽量用公开数据集或自行调研不要大规模抓取商业平台的数据这既是合规问题也是对自己项目可持续性的保护。2.2 数据清洗细节数据拿到手之后不能直接喂给模型必须经过清洗。我整理了三个最常踩的坑对应三种处理方式。第一是缺失值处理。问卷里经常有人漏掉某一项评分如果不处理后面做均值聚合时会出现 NaN。我当时按列分别处理数值型评分用该列均值填充文本型标签用“未知”填充。要特别注意“用户没吃过这道菜”和“用户吃过但没打分”的区别前者不应该参与该菜品的统计否则会把菜品平均分拉低。第二是去重和格式统一。同一个菜品在不同来源里可能叫法不一样比如“西红柿炒蛋”和“番茄炒蛋”如果直接用名字分组就会拆成两条记录。我先用归一化处理去掉全角半角差异、空格再做一轮手动别名映射把同菜不同名的项合并。第三是异常值过滤。有人可能把所有评分全打 1 分这不代表他讨厌所有菜更可能是在刷问卷。我设置了一个简单规则如果某个用户对超过 90% 的菜品都给了同一个分数就判定为无效问卷直接从数据里剔除。这个方法很粗暴但在小规模问卷场景里非常管用。2.3 构建用户-菜品特征矩阵清洗完毕的数据是一张“用户-菜品-评分”的明细表但 K-means 聚类不能直接吃这种格式它需要的是一个二维矩阵每行代表一个用户每列代表一个特征。我从三个层面构造了六维特征向量特征含义取值范围price_level价格敏感度越高越能接受高价1-3spicy辣度偏好0-5sweet甜度偏好0-5oily油腻接受度0-5speed出餐速度要求越高越看重速度1-5nutrition营养均衡关注度1-5计算逻辑是用该用户对已评分菜品的属性做加权平均。也就是说我给每道菜预先打上了这六个维度的“菜品固有属性标签”然后用户吃过的菜越多他的口味画像就越清晰。举个例子一个用户常吃麻婆豆腐、水煮肉片这类辣菜那他算出来的 spicy 均值就会接近 5聚类时自然会被分到“重口味组”。这一步做完我手里就有了一张几百行、六列的特征矩阵这才是 K-means 的标准输入。3. K-means 核心原理与代码实现3.1 K-means 算法原理与参数解析K-means 的思想用一句话就能说清随机挑 K 个点当中心然后把所有样本分给离自己最近的中心再重新计算每个组的中心点重复到中心不再移动。它的目标函数是让所有样本到所属簇中心的距离平方和最小。算法里最关键的两个点一个是距离度量默认用欧氏距离另一个是初始化。scikit-learn 里默认用 k-means 初始化它会让初始中心尽量分散避免掉进局部最优。具体参数方面我强烈建议先搞清楚这几个n_clusters簇数量 K这是唯一必须人工确定的参数确定方法下面单独说。init默认k-means尽量别改成随机初始化random否则聚类结果会很不稳定。n_init初始化次数默认 10意思是跑 10 次取最优结果可以防止一次运气不好选到差的中心。max_iter单次运行最大迭代次数默认 300数据量小时不需要管。random_state随机种子一定要固定否则每次跑出来分簇编号都不一致后续推荐逻辑和评测都没法复现。tol收敛阈值中心偏移量小于这个值就停止迭代。我实际跑下来最影响结果的三个参数是n_clusters、random_state和n_init。前面的控制分几类后两个影响可复现性和稳定性。3.2 怎么确定 K 值K 值不能拍脑袋定至少要用两种方法交叉验证。第一种是肘部法则。原理很简单随着 K 增大样本到中心的距离总和SSE/inertia会不断下降但下降速度会突然变慢那个转折点就像手臂的肘部就是比较合理的 K 值。这个结论背后的直觉是K 小于真实类别数时多分一个簇能显著降低误差K 已经超过真实类别数后再继续分簇只是在把原本合理的簇硬拆开误差下降自然就平缓了。第二种是轮廓系数。对每个样本计算它与自身簇内其他样本的平均距离 a再计算它与最近的其他簇样本的平均距离 b轮廓系数 s (b - a) / max(a, b)。s 越接近 1 说明样本离自己簇越近、离别的簇越远聚类效果越好。整体轮廓系数取所有样本的均值通常选均值最高的 K。我当时把 K 从 2 试到 10画了两张曲线图对比最后确定 K4。为什么不是轮廓系数最高的 K6因为聚类除了数学指标还要看业务解释性K6 虽然分数稍高但有两组的口味画像几乎重合区分度不够这种情况下选 K4 反而更贴近实际场景。3.3 完整聚类代码实现下面这段是我项目里的核心代码加了详细注释环境依赖是 pandas、numpy、scikit-learn、matplotlibimport pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False # 1. 读取清洗后的明细表 df pd.read_csv(campus_food_rating.csv, encodingutf-8-sig) # 2. 构造用户口味特征矩阵每个用户对六维特征求均值 user_feat df.groupby(user_id).agg({ price_level: mean, spicy: mean, sweet: mean, oily: mean, speed: mean, nutrition: mean }).reset_index() # 3. 标准化消除量纲影响非常重要 X user_feat.drop(columns[user_id]) scaler StandardScaler() X_scaled scaler.fit_transform(X) # 4. 肘部法则确定 K 值 distortions [] K_range range(2, 11) for k in K_range: km KMeans(n_clustersk, random_state42, n_init10) km.fit(X_scaled) distortions.append(km.inertia_) plt.plot(K_range, distortions, bo-) plt.xlabel(K) plt.ylabel(SSE) plt.title(肘部法则确定K值) plt.show() # 5. 轮廓系数辅助验证 sil_scores [] for k in K_range: km KMeans(n_clustersk, random_state42, n_init10) labels km.fit_predict(X_scaled) sil_scores.append(silhouette_score(X_scaled, labels)) # 6. 确定最终 K 值并训练 best_k 4 final_model KMeans(n_clustersbest_k, initk-means, n_init10, max_iter300, random_state42) user_feat[cluster] final_model.fit_predict(X_scaled) # 7. 查看每个簇的口味中心 centers pd.DataFrame( scaler.inverse_transform(final_model.cluster_centers_), columnsX.columns ) centers[cluster] range(best_k) print(centers)这里有两个细节值得解释。第一为什么标准化用StandardScaler而不是MinMaxScaler因为价格、辣度这些特征的分布基本符合正态性而且 K-means 依赖欧氏距离StandardScaler能把每个特征拉到均值 0、方差 1避免“价格”因为数值范围大而主导整个距离计算。第二打印中心点时我做了inverse_transform目的是把标准化后的数值还原回原来的评分尺度这样才能看到每个簇是“偏辣”还是“偏甜”否则中心点全是接近 0 的小数没法看业务含义。4. 推荐逻辑、系统展示与评测4.1 从聚类结果到推荐聚类完成后每个用户都带上了簇标签但“知道用户是哪类人”不等于“知道给他推荐什么菜”。这里我参考了用户画像和协同过滤的思路实现了一套简单的簇内推荐策略。策略分两层。第一层是簇内热门推荐统计每个簇的用户对哪些菜品的平均评分最高从高到低排序作为该簇的默认推荐列表。这解决的是“口味族群”共性需求。第二层是个性化混合推荐在用户所在簇的热门菜品池基础上剔除用户已经吃过的菜再优先推荐与簇中心口味距离更近的菜品。实际操作中我给每道菜也都算了一个六维口味向量然后计算“菜品向量”与“簇中心向量”的欧氏距离距离越小说明这道菜越符合这个簇的口味。推荐函数核心逻辑如下def recommend_by_cluster(user_id, user_feat, dish_df, top_n5): # 取出该用户的口味特征向量注意去掉 user_id 和 cluster 列 user_vec user_feat[user_feat[user_id] user_id].iloc[:, 1:-1].values # 用训练好的 scaler 做同样的标准化 user_vec_scaled scaler.transform(user_vec) # 预测所属簇 cluster_id final_model.predict(user_vec_scaled)[0] # 从该簇的候选菜品里按平均分排序取前 top_n pool dish_df[dish_df[popular_cluster] cluster_id] result pool.sort_values(avg_score, ascendingFalse).head(top_n) return result[[dish_name, avg_score, price_level]]这里有个容易忽略的坑预测时一定要用训练时拟合好的那个 scaler做变换不能在预测时重新 fit。一旦重新 fit整个特征空间就变了之前训练的聚类模型完全失效。正确做法是像代码里这样用全局变量里保存的scaler和final_model。另外还有一个冷启动问题如果系统来了一个从未评分过的游客他的口味向量算不出来。我当时做了一个兜底策略直接返回全校园综合评分最高的 Top-N并提示“新用户先看热门榜单吃完几道菜后推荐会更准”。这是推荐系统冷启动的标准处理方式。4.2 Flask ECharts 做个轻量展示界面算法做得再好如果只能在自己电脑的 Jupyter Notebook 里跑别人也看不到成果。我用了 Flask 起了一个极简 Web 服务前端页面用 ECharts 画图。后端逻辑很简单提供一个/recommend接口from flask import Flask, jsonify, request import joblib app Flask(__name__) # 实际部署时用 joblib 保存并加载模型组件 # joblib.dump({scaler: scaler, model: final_model}, kmeans_model.joblib) # artifacts joblib.load(kmeans_model.joblib) # scaler artifacts[scaler] # final_model artifacts[model] app.route(/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, typeint) if user_id is None: return jsonify({error: 缺少 user_id 参数}), 400 result recommend_by_cluster(user_id, user_feat, dish_df) return jsonify(result.to_dict(orientrecords)) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)前端页面除了显示推荐列表我还画了两张图一张是聚类结果的雷达图用来展示每个簇的口味画像另一张是当前用户所在簇的菜品评分 Top 10 柱状图。雷达图非常适合多维度特征展示能让非技术背景的同学一眼看明白“自己属于哪类口味”。这里建议把模型训练部分和 Flask 服务分开。训练脚本跑一次把scaler、final_model、user_feat、dish_df用joblib/pandas.to_pickle保存成文件服务启动时只加载结果不要每次启动都重新聚类。否则每次部署都跑一遍训练既慢又容易出现随机性差异。4.3 效果评测与参数调优推荐系统做完了一定要有评测环节不然你嘴上说推荐得好别人根本没法信。我用了两个层面的评测。离线指标选用轮廓系数这个前面说过。我还对比了不同 K 值下系统的推荐结果分布如果某个簇的菜品池太小少于 3 道菜说明 K 值偏大分簇过细推荐选择面太窄。在线反馈参考了简单的点击率思想。我在问卷阶段留了一部分同学作为测试用户记录他们在系统里的推荐接受度也就是“给你推荐 5 道菜你愿意去尝试几道”。测试下来聚类推荐组的平均接受度是 3.6/5而对照组看全局热门榜的接受度只有 2.7/5差距还是明显的。参数调优方面我最主要的调整是特征权重。默认情况下六个特征对距离计算的贡献一样但实际数据里spicy对分群的影响非常大把spicy单独放大后聚类结果更贴合大家的主观感受。由于 K-means 对特征缩放敏感我直接在标准化后手动给spicy乘了一个 1.5 的系数这相当于给这个特征更大的权重。这种做法虽然不太“正统”但在业务上非常有效属于聚类落地时常见的手段。5. 常见问题与排查技巧5.1 聚类结果“全挤在一团”怎么办我调试时遇到过最头疼的情况跑完聚类后所有用户几乎都在同一个簇里其余几个簇只挂着零星几个点。排查下来原因有几种。一是特征分布太偏。比如大家都只评分了少数几道菜导致六维向量里很多值趋同。解决办法是提高“最少评分菜品数”的阈值把评分记录太少的新用户剔除或者用菜品维度过滤只保留被评过 10 次以上的菜品。二是存在离群点。K-means 对离群点特别敏感一个单个用户给所有菜都打了极端分数就会吸引簇中心偏移。当时我做了离群点检测用每个样本到全数据中心的距离画箱线图把距离超出 1.5 倍四分位距的样本单独处理特征向量恢复正常后聚类效果立刻改善。三是K 值确实不合适。如果数据本身分布均匀没有明显的族群结构无论选什么 K 都很难出现清晰的簇这时候要先反思特征工程而不是继续换 K。5.2 中文菜品名和编码的坑校园美食数据免不了大量中文两处最容易出问题。一处是 CSV 文件后缀名导致的编码问题。Windows 上用 Excel 修改过的 CSV 经常是gbk或gb2312编码直接pd.read_csv()会报UnicodeDecodeError。我最后统一用encodingutf-8-sig读取这样既能读 UTF-8 存的文件也能兼容带 BOM 的文件。如果你手里的数据是 gbk就改成encodinggbk试。另一处是菜品名里的同义词。这个问题前面提到过我还加了一步用pandas.Series.str.strip()去掉首尾空格再对“辣子鸡”和“辣的鸡”这类词做人工归一化映射表。不要指望纯代码解决一切命名归一化问题校园场景菜品就几十种维护一张别名映射表成本很低效果比什么 NLP 模型都靠谱。5.3 冷启动与数据稀疏的解决方案冷启动是推荐系统绕不开的话题我的处理方案分两种。新用户冷启动没有评分数据直接返回全站热门 Top-N。这种方案简单直接缺点是没有个性化但对新用户来说已经比“无推荐可看”强太多。后续等用户评分过 3 道菜以上再启用聚类推荐。数据稀疏问卷回收的数据量不够每个用户平均只评了 8.6 道菜特征向量里的值都偏向中庸。我采取了一个数据扩增技巧在用户没吃过的菜品上不直接置 0而是按该用户群体初始聚类后的平均偏好做弱填充相当于先用粗略分群填补缺失再二次聚类细化。这个方法在实践中比直接忽略缺失列的效果好但要注意不能过度填充否则数据方差被强行压平聚类会失灵。5.4 Python 环境搭建和安装问题速查虽然这个项目用的库都是老熟人但环境问题仍然浪费了我不少时间。我整理了一份速查表问题现象解决办法scikit-learn 安装失败pip install 报错或依赖冲突尽量用 Python 3.9-3.11 版本pip install scikit-learn pandas numpy matplotlib flask一次装齐KMeans报错找不到n_init参数旧版本不支持显式传入升级 scikit-learnpip install -U scikit-learn画图中文乱码图中标题和坐标轴显示方框设置plt.rcParams[font.sans-serif] [SimHei]Flask 端口被占用启动报Address already in use换端口或kill占用进程模型预测结果每次不同聚类结果随机波动固定random_state并用joblib持久化模型我还特别提醒一句虚拟环境一定要用。项目开始前新建一个conda create -n foodrec python3.10或者python -m venv venv否则全局环境里各种库版本互相打架排查依赖冲突的时间往往比写代码还长。最后的实话这个项目做下来我最大的感受是K-means 本身不难难点全在算法外围。数据清洗占了我差不多一半的时间特征工程又是一大块真正的聚类代码可能只花了半天。这也想给准备照做的人提个醒如果你现在卡在“模型跑出来的效果不好”先别急着换算法回头看看特征矩阵到底干不干净、标准化的尺度有没有问题多半是数据或特征的锅。另外一个很重要的经验是推荐系统这类项目一定要保留一个能人工解释的“口子”。K-means 的优势在于每个簇都能说出来代表什么口味这让你的推荐结果有业务逻辑支撑无论是写论文、做答辩还是给同学演示都比一个黑盒模型更有说服力。如果后续想延伸可以在这个基础上加时间维度比如把“午餐”和“晚餐”分开建模或者把聚类推荐和协同过滤做加权融合弥补单一算法的局限。但对校园美食这个场景来说K-means 已经是一把足够趁手的刀了。
返回列表