简介:面向高校计算机专业课程设计与期末项目的机器学习完整实践资源,以米其林餐厅星级、地理位置、菜品特色等多维数据为分析对象,解决理论学习与实际项目脱节的问题,系统覆盖数据清洗、特征选择、分类模型构建与可视化展示等核心环节。压缩包共56个文件,以18个Java源文件承载主体逻辑,10个FTL模板与5个CSS样式搭建前端展示,4个CSV数据表和SQL脚本提供原始数据及建表方案,另有9个zbak备份、README说明等附件,整体仅276KB,模块化结构便于研读与二次开发。目前已有52人学习下载。资源附带完整课程设计文档、代码注释与操作指引,可帮助理解机器学习项目从需求分析到系统实现的完整生命周期,掌握餐饮行业数据挖掘的真实应用场景;数据预处理、特征工程与分类建模的代码均可直接复用,也可作为高分课程设计的重要参考。
1. 把米其林餐厅数据挖掘从“想”变成“能跑”:这套机器学习源码包先解决什么
我见过太多数据挖掘课程设计止步于“数据集下载好了、图也画了两张,模型一跑就过拟合”。米其林餐厅这个题看着热闹,真要做出一个能交差的系统,难度集中在三处:公开数据怎么筛、星级标签怎么定、模型怎么调才能解释“什么特征的餐厅更容易拿星”。“基于机器学习的米其林餐厅数据挖掘系统源码与课程设计文档”这份资源,就是把这三件事打包成一条可复现的链路:从原始餐厅记录清洗、构造特征、完成探索性分析,到用分类模型预测星级并输出演示接口。适合两类人:一类是正在做机器学习课设、需要完整工程骨架和数据挖掘流程的学生;另一类是刚入门、想找一个“数据能对齐、代码能跑通、报告能对应上”的实战项目的人。下面按我拆解的顺序,把字段设计、建模参数和踩过的坑一条条说透。
2. 数据层设计:定好字段再谈建模,星级标签是整条链路的锚点
2.1 数据采集与字段设计:先想清楚要预测什么,再决定留哪些列
我一般拿到这类数据挖掘项目,不会先去关心模型选什么,而是先看数据字典。这个资源里的做法是按“餐厅实体”组织记录,核心预测目标是“星级标签”,其余字段全部围绕“能不能成为候选特征”来判断。常见门店记录大致长这样:
| 字段 | 类型 | 示例 | 用途 |
|---|---|---|---|
| city | 文本 | 巴黎、东京、上海 | 城市级别分布 |
| restaurant_name | 文本 | XX Bistrot | 汇总维度 |
| cuisine_type | 文本 | 法餐、日料、融合菜 | 类别特征 |
| price_range | 文本 | €€ | 价格区间 |
| price_mid | 数值 | 120 | 人均价格中位数 |
| star_level | 整数 | 0/1/2/3 | 目标标签 |
| rating_score | 数值 | 4.7 | 用户评分特征 |
| awards_count | 整数 | 2 | 获奖次数特征 |
注意一个容易翻车的点:star_level必须是“截至当前年份的星级状态,而不是把历史上拿过的星都堆进来”。很多入门项目把星级处理成“长期属性”,导致同一家店在不同年份的记录互相矛盾,模型学到的不是规律而是脏数据。这个资源在文档里特别标注了“按年份截断”的规则,我的建议是照抄这个习惯:每条样本固定一个eval_year,清洗时只保留该年份之前可公开获得的信息,这也决定了特征里不能出现“未来变量”。
打开原始 CSV 之后,第一步通常是重编码列名、去除 BOM 和空白字符,并解析价格区间:
import pandas as pd # 用 utf-8-sig 而不是 utf-8,避免 Windows 下中文表头出现 \ufeff df = pd.read_csv("data/michelin_raw.csv", encoding="utf-8-sig") df.columns = [c.strip().replace("\ufeff", "") for c in df.columns] # 把 "€€" 这类符号映射成数值中位数,供后续模型使用 def price_to_mid(text): if not isinstance(text, str): return None symbols = text.count("€") if symbols == 0: return None return symbols * 150 # 常见做法:单 € 记为 150 左右的人均区间中值 df["price_mid"] = df["price_range"].apply(price_to_mid) df["star_label"] = df["star_level"].abs().fillna(0).astype(int) print(df[["city", "cuisine_type", "price_mid", "star_label"]].head())这里三个细节值得展开。第一,encoding="utf-8-sig"是标准解法,因为很多 CSV 是从 Excel 导出的,带 BOM 会导致第一列名变成\ufeffcity。第二,price_to_mid里用count("€")统计符号个数,比正则更快也更稳;单人套餐价格在不同城市差异很大,资源文档里用固定系数去换算,实际项目中你可以按城市分组再校准。第三,star_label对所有非空星级取绝对值,目的是把清洗阶段不关心的负值脏数据先拉平,后面再过滤。
2.2 清洗的一致性口径:空值、重复门店和异常价格是三个隐性杀手
数据层真正的难点不是“有没有空值”,而是“空值能不能补、重复要不要去、异常价格是真实还是录入错误”。这个资源在清洗脚本里踩了两条非常关键的点,我拆的时候感受很深。
第一条是重复门店。同一家餐厅在不同点评平台可能有多个英文名变体,比如“L'Ambroisie”和“Lam Broisie”极易被当成两家店。常见做法是先按city + restaurant_name做精确去重,再把编辑距离低于阈值的名称合并:
from rapidfuzz import process, fuzz cities = df["city"].unique() dedup_ids = [] for city in cities: sub = df[df["city"] == city].copy() names = sub["restaurant_name"].tolist() matched = {} for name in names: # 与同城已有候选做模糊匹配,阈值 88 是工程上比较稳的经验值 result = process.extract(name, list(matched.keys()), scorer=fuzz.token_sort_ratio, limit=1) if result and result[0][1] >= 88: matched[result[0][0]] = max(matched.get(result[0][0], 0), sub.loc[sub["restaurant_name"] == name, "rating_score"].mean()) else: matched[name] = sub.loc[sub["restaurant_name"] == name, "rating_score"].mean() dedup_ids.extend(matched.keys())不要小看这一步。如果不去重,特征里会凭空多出一批“高重复、高相关”的样本,导致交叉验证分数虚高,外部评估时直接崩盘。第二条是价格异常。真实公开数据里“€€€€€”这种五符号定价不是高级,而是商家没维护字段;还有价格区间里混入“$$$”的美式符号,和“€€”不是一套体系。我的处理习惯是:解析结果超过四档就置空,置空后如果同一家店还有历史记录,用历史中位数回填;否则直接删除该行的价格特征,但不删整行。
清洗脚本跑完后做一次硬校验:打印每个字段的空值比例、star_label的类别分布、去重前后的行数差。这三组数字会直接决定后续要不要用类别均衡策略,所以建议在进入特征工程前先把它作为基线记录下来。
3. 特征工程与探索性分析:用数据说明“什么样的餐厅更容易拿星”
3.1 类别特征编码:城市、菜系和获奖次数不是同一种编码方式
数据清洗干净后,就要进入数据挖掘里最影响模型效果的部分——特征工程。这个资源把特征分成三组:类别型(城市、菜系)、数值型(价格中位数、评分)、计数型(获奖次数、分店数)。新手最容易犯的错误是“所有类别全部 one-hot”,导致城市维度变成几百列稀疏矩阵,随机森林完全学不动。
我推荐的做法是分组处理。城市这种取值多、样本分布极不均衡的字段,先统计频次,出现次数低于阈值(比如少于 5 次)的合并为“other”,再做 one-hot;菜系字段保留 top 10,其余合并;获奖次数是计数型,直接保留原值,它比 any one-hot 更符合业务直觉。代码路径如下:
from sklearn.preprocessing import OneHotEncoder, StandardScaler # 低频城市合并,防止维度爆炸 city_counts = df["city"].value_counts() low_freq_cities = city_counts[city_counts < 5].index df["city_grouped"] = df["city"].replace(low_freq_cities, "other") # 菜系 top10 + 其他 top_cuisines = df["cuisine_type"].value_counts().head(10).index df["cuisine_grouped"] = df["cuisine_type"].where(df["cuisine_type"].isin(top_cuisines), "其他") encoder = OneHotEncoder(handle_unknown="ignore") encoded = encoder.fit_transform(df[["city_grouped", "cuisine_grouped"]]) scaler = StandardScaler() price_scaled = scaler.fit_transform(df[["price_mid"]])这段代码里有个值得注意的点:handle_unknown="ignore"必须加,否则训练集里没出现过的城市会在预测阶段直接报错。标准化放在 one-hot 之后做,顺序不能反——先编码再缩放,数值型特征和类别型特征各走各的通道,后续模型才能公平对待它们。获奖次数和评分这些特征我一般也纳入标准化,防止评分跨度(3.5 到 4.9)主导梯度。
3.2 EDA 验证特征区别度:别用相关系数矩阵应付,用分组差异说话
课程设计文档里最难看的部分就是探索性分析写成了“画几张分布图再加一段描述”。这个资源倒是给了个不错的方向:把每个特征按星级分组,用统计检验判断“拿星组”和“未拿星组”的分布差异。这样写进报告里是加分项,因为它从“看起来不同”变成“统计上显著不同”。
from scipy.stats import mannwhitneyu def cluster_separated(p): return "<0.001" if p < 0.001 else round(p, 4) for col in ["price_mid", "rating_score", "awards_count"]: group_a = df.loc[df["star_label"] > 0, col].dropna() group_b = df.loc[df["star_label"] == 0, col].dropna() stat, p = mannwhitneyu(group_a, group_b, alternative="two-sided") print(f"{col}: p={cluster_separated(p)}")mannwhitneyu是非参数检验,适合金融士数据里常见的偏态分布,不需要假设正态性。如果输出的 p 值大于 0.05,说明这个特征对区分星级基本没有帮助,可以直接扔出候选集,避免给模型灌噪声。我跑这部分时看到的结果是:price_mid和awards_count的区分度非常明显,而rating_score的 p 值偏大——用户评分和米其林官方评审本来就是两套体系,这个现象在报告里是很好的论据。
特征工程做完,建议产出一张“特征矩阵行数 × 列数”的记录,并标明哪些是派生列、哪些是原始列。深度学习项目反而不需要这么较真,但课程设计答辩时,老师第一个问题就是“你这个特征怎么来的”,这套记录能让你少说很多废话。
4. 建模与调参:逻辑回归、随机森林和代价敏感策略的配合
4.1 基线模型选择:先跑通逻辑回归再上随机森林
分类模型在餐厅星级预测上的第一大难点是类别严重不平衡:大部分样本的star_label = 0,少数是一星、二星、三星。这种场景下不要上来就调 LightGBM,先拿逻辑回归做基线,它的可解释性最好,也最容易暴露出数据准备阶段的漏洞。
from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import f1_score, roc_auc_score X_train, X_test, y_train, y_test = train_test_split( feature_matrix, df["star_label"], test_size=0.2, stratify=df["star_label"], random_state=42 ) clf_base = LogisticRegression(max_iter=1000, class_weight="balanced") clf_base.fit(X_train, y_train) y_pred = clf_base.predict(X_test) y_proba = clf_base.predict_proba(X_test)[:, 1] print(f"F1: {f1_score(y_test, y_pred):.3f}") print(f"AUC: {roc_auc_score(y_test, y_proba):.3f}")class_weight="balanced"是处理不平衡的第一层手段,它按类别频率反比放大少数类的损失权重,完全不需要手工造样本。stratify=y_train保证切分后训练集和测试集的星级比例一致,否则随机切分可能让测试集里一个三星样本都没有,AUC 指标直接失真。基线跑完后看 AUC 更有意义,因为 AUC 不依赖分类阈值,能更真实反映特征排序能力。
4.2 随机森林与网格搜索:参数取值背后的实际含义
逻辑回归跑通后,下一步是换成随机森林,因为它能自动捕捉特征交互,比如“巴黎 + 法餐 + 高价格”的组合。这个资源里给了可复现的网格搜索配置,我把参数和含义拆一下:
| 参数 | 搜索范围 | 说明 |
|---|---|---|
| n_estimators | 200/300/500 | 树的数量,越多越稳,但超过 500 收益递减显著 |
| max_depth | 8/12/16 | 限制单棵树深度,防过拟合的关键 |
| min_samples_leaf | 3/5/10 | 叶节点最少样本数,值越大模型越保守 |
| class_weight | balanced/sample_weight | 少数类权重,sample_weight 适合按误差代价精细控制 |
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GridSearchCV, StratifiedKFold param_grid = { "n_estimators": [200, 300], "max_depth": [8, 12, 16], "min_samples_leaf": [3, 5, 10], "class_weight": ["balanced"] } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV( RandomForestClassifier(random_state=42), param_grid, scoring="roc_auc", cv=cv, n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)网格搜索跑完之后,不要只把best_score_抄进论文。要检查两个东西:第一,最佳参数是不是落在搜索边界上,如果最优max_depth=16正好是最大值,说明范围没设够,应该把搜索上限再往上加;第二,交叉验证得分和测试集得分的差距,如果差距超过 0.05,说明参数对数据波动太敏感,回退到更保守的min_samples_leaf=10是更好的选择。
4.3 模型解释与结论沉淀:特征重要度告诉你业务故事
模型调完,课程设计里需要一段“结果分析”。我习惯把随机森林的feature_importances_取出来排序,再结合第一部分的名次比较表说明哪组特征体系更有效。有个细节:默认重要度是gini纯度下降量,它对高基数类别特征有偏袒;如果想得到更稳定的排序,建议用permutation_importance再验证一次,需要的额外时间不多,但解释力强很多。
特征重要度不是模型告诉你“哪个特征应该保留”,而是“在现有数据分布下哪个特征对划分贡献大”。写报告时一定要加上这句限定,不然答辩时老师用另一个城市的数据一验证,结论很容易被反驳。
5. 避坑与常见问题:标签泄漏、过采样和中文编码实战排查
5.1 现象:训练集 AUC 高达 0.98,测试集却很平庸
这是数据挖掘项目最典型的“虚高分”。“高”的原因不是模型强,而是特征里混进了只在事后才能获得的信息。在这个餐厅项目里,最隐蔽的泄漏源是“历史获奖记录被当成特征”:如果这条样本的预测目标就是今年拿星,那去年获奖次数本质上已经包含了评审结果,模型等于在抄答案。
解决方法是做一次“时间边界检测”。把eval_year作为切分依据,用 2020 年以前的样本训练,预测 2021 年的星级,看 AUC 是否断崖式下降。如果是,基本可以断定存在未来信息泄漏。清洗时把这些强相关字段延迟到标签构造阶段,只保留“评审前可知”的公开信息。
5.2 现象:过采样后验证集 F1 很高,线上却差得离谱
很多入门者在切分前直接跑SMOTE,导致少数类副本同时出现在训练集和验证集里,验证分数虚高。这是顺序错误,不是 SMOTE 本身的问题。
解决方法是严格遵循“先切分,后过采样只作用于训练集”的顺序。先用train_test_split固定测试集,再对训练集做SMOTE,验证集保持原始分布。另一个更省事的选择是class_weight="balanced",它能从损失函数层面实现类似效果,且不会产生样本副本。课程设计文档如果写了过采样,评审老师第一个问题就是“你过采样前切分了没有”,准备好这个答案很关键。
5.3 现象:CSV 读进来中文全乱,且第一列名带奇怪符号
这一眼看上去是玄学,其实是编码规则。utf-8和GBK混用时用pd.read_csv默认参数读取,一定会报UnicodeDecodeError,或者打开后出现“锟斤拷”乱码。
解决方法是统一用utf-8-sig读取,或者做成编码自动探测:
def load_csv_with_fallback(path): for enc in ["utf-8-sig", "gbk", "latin1"]: try: df = pd.read_csv(path, encoding=enc) return df except (UnicodeDecodeError, UnicodeError): continue return pd.read_csv(path, encoding="utf-8", errors="ignore")我从那以后所有数据路径都默认走这个函数,能省掉一半的“莫名其妙的报错”。另外要注意,utf-8-sig还会把不可见的零宽字符一并引入,列名清洗时用str.replace("\u200b", "")很必要。
5.4 现象:同一家餐厅价格解析结果前后不一致
这是特征口径问题。部分数据源把价格写成“€€ - €€€”,解析策略不同就会得到 300 和 600 两个结果,同一条样本在训练和预测时特征值不同,模型行为自然不可复现。
我的习惯是写一个固定的解析函数并把它固化进数据预处理模块,所有下游统一调用:
def parse_price_mid_stable(value): if not isinstance(value, str): return None value = value.replace(" ", "").replace("€"*3, "300") # 统一按范围取中间值而非上界 if "-" in value: low, high = value.split("-", 1) return (int(low) + int(high)) / 2 return None关键是“范围取中位数”这个决策,一旦确定不要在中途改。每次改特征口径都要重新跑一遍全链路并从数据清洗开始,而不是只重训模型。
5.5 现象:评估分类报告时只看准确率,忽略类别差异
餐厅星级预测里 90% 的样本是未拿星餐厅,全部预测成 0 也有 90% 准确率,但模型等于没用。正确做法是以加权的 F1、macro F1 和各星级的召回率为准。对非星级餐厅,允许误判;对三星餐厅,漏掉一个远比误判一个严重,因为三星本身代表头部案例,漏检会导致整个判断系统失去可信度。
课程设计文档里建议把分类报告输出为表格,按星级分别列出精确率、召回率和 F1,而不是只贴一个 accuracy。这样既显得分析扎实,又能规避准确率带来的误导。
6. 输出可复现的演示系统与课程设计报告:把模型封装成能答辩的结论
进入收尾阶段,不再新增复杂模型,而是把前面的产物整理成一键运行的系统。这个资源包里的旁路脚本默认提供了一个很实用的入口——用命令行输入餐厅的城市、菜系和人均价格,直接输出预测星级和置信度。我把它提炼成下面这段骨架:
python main.py --city 巴黎 --cuisine 法餐 --price_mid 120执行结果示例:
预测星级: 一星 置信度: 0.74 主要驱动特征: 城市=巴黎, 价格中位数=120, 获奖次数=1--price_mid是数值型参数,--city和--cuisine走文本编码器,命令行入口的好处是答辩时现场演示极其方便,不用打开 Jupyter Notebook 一格格跑。注意代码里要加载的是训练阶段保存的完整 pipeline,包括编码器、标准化器和模型三者,而不是只保存模型本身。只存模型是高频翻车点,换一台机器预测时特征列顺序不一致,结果直接错位。
如果时间允许,在演示之后加一段概率校准说明会非常加分。predict_proba输出的 0.74 并不能直接解释成“有 74% 概率拿到一星”,需要做一次 Platt 缩放或温度缩放。不过课程设计一般不需要做这一步,把原始概率当作置信度排序展示即可。
报告方面,最关键是结果章节的结构:先说数据来源和清洗规则,再展示 EDA 的两张显著性结论表,然后是模型对比表和最佳参数,最后附上演示截图。答辩老师通常会在三分钟内扫完这个结构,任何一块缺失都会被追问。别问我是怎么知道的,我第一次就是这么翻车的。
从那以后,我每次拆这类数据挖掘源码包,都强制先走一遍“数据清洗校验 → 切分顺序检查 → 特征口径核实”三步流程,再动手看模型结果。这套资源给我最大的价值不是模型本身,而是帮我把“拿到数据就能复现”的流程规范下来了:从utf-8-sig读取到 stratify 切分,每一步都是可以迁移到下一个项目里的纪律。希望这篇拆解能让你少踩几个我踩过的坑,也帮你把这份源码价值挖得更透。
本文还有配套的精品资源,点击获取