ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高校食堂点评系统实战:低样本下的评分排序与推荐算法

高校食堂点评系统实战:低样本下的评分排序与推荐算法 简介这是一套面向高校学生与教职员工、基于PHP开发的食堂菜品点评Web应用源码适合Web开发初学者、课程设计或毕业设计参考者用于实现菜品打分、评论互动、食堂信息展示与数据分析等功能。压缩包共146个文件约43.18MB包含39个PHP页面脚本、63张jpg菜品与界面配图、27个txt说明文件以及html、css、js等前端资源另附doc与docx需求设计文档、db数据库文件和xml配置rar打包便于整体部署与二次开发。已有843人学习下载。资源完整呈现了从数据库设计、HTTP请求处理到前端交互的实现思路配套文档可帮助理解用户角色、功能需求与表结构设计测试文件则提供排错与验证参考是学习PHP结合MySQL开发实用项目的典型案例。1. 高校食堂点评系统从档口数据到推荐排序一套能跑通的落地路径高校食堂点评系统本质是把「哪个档口、哪道菜、什么时段、多少钱、好不好吃」这五类信息结构化再叠加上评价与排序最终让一个学生打开页面就能决定今天吃什么。它和大众点评的差别不在功能多少而在数据密度极低、评价者高度同质、档口更新极快——一个档口可能这学期还在下学期就换了招牌。所以真正难的不是写一个点评页面而是让数据在低样本、高噪声、强时效的条件下依然能给出靠谱的推荐。这套系统适合两类人一类是想拿它做课程设计或毕业设计的同学另一类是想在校园里真正跑起来、让身边人用起来的小团队。下面按「数据怎么来、评分怎么算、排序怎么做、坑在哪」的顺序把一条能复现的路径讲清楚。2. 数据建模与采集档口、菜品、评价三张表怎么设计才不返工2.1 为什么不能照搬电商的商品-订单模型电商的模型是「商品稳定、订单高频」一个 SKU 可以卖三年评价累积到几万条。食堂场景正好反过来档口生命周期短菜品可能一周换一次单个菜品的评价可能只有十几条。如果照搬「商品表 订单表 评价表」你会遇到两个问题第一菜品下架后评价变成孤儿数据统计口径混乱第二档口和菜品是两层结构但学生实际评价的对象往往是「某档口的某道菜」而不是档口整体。我一般会把核心实体拆成三层stall档口、dish菜品、review评价。档口是物理摊位菜品挂在档口下评价同时关联档口和菜品。这样做的原因是当某个菜品下架时评价仍然可以按档口聚合不会丢数据。另外加一张stall_daily表做每日快照记录档口当天的营业状态和菜品列表方便做时间维度的对比。2.2 三张核心表的字段与索引设计下面是最小可用的建表语句用 SQLite 写方便本地跑通换 MySQL 只需改自增和类型。-- 档口表一个物理摊位 CREATE TABLE stall ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 档口名如「一楼麻辣香锅」 floor INTEGER NOT NULL, -- 楼层用于筛选 category TEXT, -- 品类如「川菜」「面食」 status INTEGER DEFAULT 1, -- 1营业 0停业 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 菜品表挂在档口下 CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, stall_id INTEGER NOT NULL, name TEXT NOT NULL, -- 菜名 price REAL NOT NULL, -- 价格单位元 tags TEXT, -- 逗号分隔标签如「辣,大份」 status INTEGER DEFAULT 1, FOREIGN KEY (stall_id) REFERENCES stall(id) ); -- 评价表同时关联档口和菜品 CREATE TABLE review ( id INTEGER PRIMARY KEY AUTOINCREMENT, stall_id INTEGER NOT NULL, dish_id INTEGER, -- 可为空表示只评档口 user_id TEXT NOT NULL, -- 匿名用户标识 score REAL NOT NULL, -- 1~5 分 content TEXT, -- 文字评价 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (stall_id) REFERENCES stall(id), FOREIGN KEY (dish_id) REFERENCES dish(id) ); -- 关键索引按档口查评价、按菜品查评价 CREATE INDEX idx_review_stall ON review(stall_id); CREATE INDEX idx_review_dish ON review(dish_id); CREATE INDEX idx_dish_stall ON dish(stall_id);逻辑说明review表同时保留stall_id和dish_id是为了支持两种查询——「这个档口整体怎么样」和「这道菜怎么样」。dish_id允许为空是因为有些评价只针对档口环境或服务不针对具体菜。索引建在三个外键上因为最高频的查询就是「按档口/菜品聚合评分」。参数说明score用 REAL 而不是 INTEGER是为了后续做加权平均时保留小数user_id用 TEXT 而不是自增整数是因为匿名标识可能来自学号哈希或设备指纹格式不固定。tags用逗号分隔是妥协方案如果标签需要筛选建议单独建dish_tag表。2.3 采集入口扫码、表单、还是爬聊天记录数据从哪来决定了系统能不能活。常见做法有三种第一种是档口贴二维码学生扫码进入评价页优点是精准缺点是扫码率极低第二种是食堂出口放评价机或小程序入口优点是流量集中缺点是评价和具体菜品对不上第三种是从已有的群聊、问卷里做半自动导入优点是冷启动快缺点是格式脏。我一般会组合使用先用问卷做冷启动把前 200 条评价灌进去让页面看起来不是空的然后在小程序里做「吃完随手评」把评价入口放在支付成功页后面转化率最高。导入时用一个简单的清洗脚本把「太咸了」「分量足」这类短句映射到标签而不是直接存原文。# 评价短句转标签的极简规则引擎 TAG_RULES { 辣: [辣, 麻辣, 变态辣], 咸: [咸, 太咸, 齁], 分量足: [分量足, 量大, 吃不完], 性价比高: [便宜, 实惠, 划算], 排队久: [排队, 等了好久, 人太多], } def extract_tags(content: str) - list: tags [] for tag, keywords in TAG_RULES.items(): if any(kw in content for kw in keywords): tags.append(tag) return tags # 示例 print(extract_tags(这个麻辣香锅太咸了但是分量足)) # 输出 [辣, 咸, 分量足]逻辑说明规则引擎比模型更适合冷启动因为评价量少、领域窄规则可解释、可随时改。参数说明TAG_RULES的键是标准标签值是同义词列表后续可以按学校口味补充。注意这里用的是「包含」而不是「等于」因为评价是短句不是单词。3. 评分与排序低样本下怎么让「好吃」不被噪声淹没3.1 直接算平均分会翻车因为样本太少假设一个档口只有 3 条评价分别是 5 分、5 分、1 分平均分 3.67看起来还行。但另一个档口有 100 条评价平均分 4.2。如果直接按平均分排序前者可能因为一条差评就掉到后面后者因为样本多而稳定。更极端的情况是一个新档口只有 1 条 5 分评价平均分 5.0直接排第一这显然不合理。解决方法是贝叶斯平均也叫加权评分。核心思想是给每个档口一个先验分数当评价数少时最终分数靠近先验评价数多时靠近真实平均。公式如下加权分 (v / (v m)) * R (m / (v m)) * C其中v是评价数R是平均分m是平滑参数一般取 5~20C是全站平均分。这个公式的好处是新档口不会因为一条好评就冲顶老档口也不会因为一条差评就崩盘。3.2 用 Python 实现加权评分与排序下面是一个可直接跑的排序脚本输入是评价列表输出是排序后的档口。from collections import defaultdict def weighted_score(reviews, m10, C3.8): reviews: list of (stall_id, score) m: 平滑参数越大越保守 C: 全站平均分可动态计算 # 按档口聚合 stall_scores defaultdict(list) for stall_id, score in reviews: stall_scores[stall_id].append(score) result [] for stall_id, scores in stall_scores.items(): v len(scores) # 评价数 R sum(scores) / v # 平均分 # 贝叶斯加权 wr (v / (v m)) * R (m / (v m)) * C result.append((stall_id, wr, v, R)) # 按加权分降序 result.sort(keylambda x: x[1], reverseTrue) return result # 模拟数据档口1有3条评价档口2有50条 reviews [(1, 5), (1, 5), (1, 1)] [(2, 4.2)] * 50 for stall_id, wr, v, R in weighted_score(reviews): print(f档口{stall_id}: 加权分{wr:.2f}, 评价数{v}, 原始均分{R:.2f})逻辑说明m10表示当评价数达到 10 条时先验和真实均分各占一半。C3.8是全站平均分实际使用时应该从数据库动态计算而不是写死。输出中可以看到档口1虽然有一条 1 分但因为样本少加权分被拉向 3.8档口2样本多加权分接近 4.2。参数说明m的取值很关键。m太小新档口容易冲顶m太大老档口的变化不敏感。我一般会从 10 开始试如果发现新档口排名波动大就调到 15 或 20。C建议每周重算一次避免全站口味漂移。3.3 时间衰减让「最近好不好吃」比「以前好不好吃」更重要食堂档口的质量波动很大可能换了个厨师就变难吃。如果所有历史评价等权一个档口半年前的好评会掩盖最近的问题。常见做法是给评价加时间衰减因子权重 exp(-λ * 天数差)λ越大衰减越快。λ0.01时30 天前的评价权重约 0.74λ0.03时30 天前只有 0.41。实际使用时把权重乘到评分上再算加权平均。import math from datetime import datetime def time_decay_weight(created_at, nowNone, lam0.02): if now is None: now datetime.now() days (now - created_at).days return math.exp(-lam * days) # 示例30天前的评价 old datetime(2024, 1, 1) now datetime(2024, 1, 31) print(time_decay_weight(old, now)) # 约 0.55逻辑说明时间衰减让排序更贴近「现在好不好吃」而不是「历史上好不好吃」。参数说明lam建议在 0.01~0.03 之间调太大则只有最近几天有效太小则衰减不明显。注意衰减因子要乘在评分上而不是直接乘在最终加权分上否则会破坏贝叶斯平均的数学性质。4. 避坑与排查上线后最容易翻车的五个地方4.1 评价刷分同一个人一天评了 20 次现象某个档口的评分在短时间内暴涨评价内容高度相似甚至出现「好吃好吃好吃」这种重复文本。原因没有做用户维度的频率限制或者匿名标识可以被轻易重置。解决在review表上加唯一约束限制同一user_id对同一dish_id每天只能评一次同时对文本做去重连续相同字符超过 5 个的直接拒绝。更狠一点的做法是把评价和支付记录绑定没有支付就没有评价资格。4.2 档口合并后评价错乱现象两个档口合并成一个或者一个档口换了老板但名字没变历史评价混在一起排序失真。原因档口表没有做版本管理stall_id直接复用。解决给stall表加version字段合并或换老板时新建一条记录旧记录标记为停业评价按stall_id自然隔离。查询时只查status1的档口。4.3 价格字段用浮点数导致排序误差现象按价格排序时9.9 和 10.0 的顺序偶尔不对。原因SQLite 的 REAL 类型在比较时存在浮点误差。解决价格用整数存「分」展示时除以 100。这是血泪经验早期用 REAL 存价格后来做价格区间筛选时发现边界值总是差一分钱。4.4 标签体系膨胀到无法维护现象一开始只有 10 个标签三个月后变成 200 个很多标签只出现过一次。原因没有做标签归并用户输入的自由文本直接变成标签。解决标签只允许从预定义列表里选自由文本走规则引擎映射每月做一次标签统计出现次数少于 5 次的标签合并到上级标签。4.5 排序结果每次刷新都不一样现象同一个用户刷新页面档口顺序变了。原因排序时用了ORDER BY score DESC但 score 相同的档口没有稳定的次级排序键。解决加一个稳定的次级排序比如ORDER BY score DESC, stall_id ASC。如果用了随机因子做探索要把随机种子固定下来或者把探索结果缓存 5 分钟。5. 进阶技巧用「时段偏好」做个性化推荐前面讲的排序是全站统一的但食堂场景有一个很强的信号同一个人早餐和晚餐想吃的完全不一样。早餐可能只想找「出餐快、便宜、不辣」的晚餐可能想找「分量足、口味重」的。如果把时段信号加进去推荐会准很多。具体做法是在review表里加一个meal_time字段取值breakfast、lunch、dinner、night。查询时先按当前时段筛选评价再算加权分。如果某个档口在早餐时段的评价数少于 5 条就回退到全时段评分避免冷启动问题。def score_by_meal_time(reviews, meal_time, m10, C3.8): reviews: list of (stall_id, score, meal_time) 只统计指定时段的评价不足则回退全时段 from collections import defaultdict filtered defaultdict(list) all_scores defaultdict(list) for stall_id, score, mt in reviews: all_scores[stall_id].append(score) if mt meal_time: filtered[stall_id].append(score) result [] for stall_id in all_scores: scores filtered.get(stall_id, []) if len(scores) 5: # 时段样本不足回退 scores all_scores[stall_id] v len(scores) R sum(scores) / v wr (v / (v m)) * R (m / (v m)) * C result.append((stall_id, wr, v)) result.sort(keylambda x: x[1], reverseTrue) return result逻辑说明meal_time从评价时间自动推断比如 6:00~10:00 算早餐10:00~14:00 算午餐16:00~20:00 算晚餐20:00 以后算夜宵。参数说明回退阈值设为 5 条是因为少于 5 条时贝叶斯平均的先验占比太高时段信号反而变成噪声。这个阈值可以根据实际数据量调整评价多的学校可以降到 3 条。另一个技巧是「同价位对比」。学生选餐时价格是硬约束。与其推荐一个 30 元的档口给预算 15 元的人不如在 10~15 元区间内做排序。实现上就是在查询时加一个price BETWEEN ? AND ?的条件再算加权分。这个改动很小但体验提升很明显。最后说一个我自己的习惯每次改排序算法之前先把当前结果导出成 CSV改完之后做 diff。如果 Top 10 里有超过 3 个档口变了就要问自己「这个变化是用户想要的吗」。排序算法的改动很容易变成玄学调参有一个可回滚的基线比什么都重要。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进