餐饮数据分析与营收预测:从特征工程到LightGBM建模实战 简介面向餐饮行业数据分析与机器学习实践的综合资源适合餐饮企业数据分析师、机器学习初学者及业务决策人员也适合高校相关课程实验。内容紧扣餐饮销售预测、用户流失分析、菜品推荐与关联规则挖掘等典型任务完整覆盖数据获取、预处理、特征工程、探索性可视化、模型构建与评价等关键环节可帮助读者掌握从原始数据到业务洞察的落地路径。压缩包共43个文件以27个CSV数据文件订单明细、用户信息、销售流水、菜品数据等和6个Python脚本数据处理、探索分析、建模、评价、关联规则等为核心辅以7张可视化分析图、2个Excel字段说明及1个npz中间数据文件整体仅1.19MB结构清晰、分层明确。已有242人学习下载。借助资源中的多维餐饮数据与自相关图、时序图、客户特征分布图等分析结果可快速复现未来3天营业额预测、菜品相似度计算和推荐系统构建等实验还能结合字段说明理解数据含义为实际经营决策与算法调优提供可复用的参考。1. 餐饮数据分析预测先搞清楚这套数据到底能回答什么问题一家连锁餐饮店的老板每天最头疼的几件事无非是明天备多少货、周末哪个菜品会爆单、下个月营收能到多少。凭经验拍脑袋的做法在门店少的时候还凑合一旦上了规模误差就是每天几千块的食材浪费或者高峰期断货。而这个「机器学习-餐饮企业综合数据分析及预测」项目本质上就是拿一套真实的餐饮业务数据完成从数据清洗、特征工程到营收/销量预测建模的完整链路。适合刚入门机器学习、想拿业务数据练手的人也适合餐饮行业里想用数据替代直觉的运营人员。先说结论这套方案做出来之后最实用的产出不是那个最终预测准确率而是你对「餐饮数据里哪些字段真的影响生意」的认知。我见过太多人拿到数据集就急着调参结果做了两周发现连预测目标都定错了——有人拿来做用户复购预测有人拿来做菜品推荐方向完全跑偏。这篇文章会把数据集的字段含义、预测任务的边界、特征工程的套路和模型训练的坑一次讲清楚。2. 数据集结构与预测目标别急着跑模型先定清楚业务口径2.1 数据字典里藏着哪些业务信息餐饮企业的数据一般不会只有一个表常见的数据集至少包含订单明细表、门店信息表、菜品表和日历表。拿到「含数据集及说明」的项目压缩包之后第一步不是打开 Jupyter 就开始 groupby而是先把每个字段的业务含义对着说明文档捋一遍。典型的订单明细表字段包括订单号、门店编号、下单时间、菜品编号、菜品分类、数量、单价、折扣金额、实付金额、支付方式、订单渠道堂食/外卖/自提。门店表则包含门店编号、城市、商圈类型、门店面积、开业日期、是否提供外卖服务。菜品表有菜品编号、所属菜系、成本价、建议售价、上下架日期。这里有一个容易被忽略的点实付金额和折扣金额这两个字段的关系。很多餐饮订单会有满减、会员折扣、新客立减如果直接用实付金额做营收预测就会把营销活动的影响也混进去。我一般会单独构建一个「折扣力度」特征——折扣金额除以原价总额这个特征在后面的特征重要性排序里往往排在前五。2.2 预测任务定错后面全白做这个标题里「综合数据分析及预测」写得很宽但落地时你必须二选一要么预测门店/总体的日营收要么预测具体菜品的日销量。这两个任务的难度和数据要求完全不同。日营收预测是一个典型的时序回归问题特征以日期、星期、节假日、天气、门店属性为主模型输出是一个连续值。而菜品销量预测的难度大得多因为单个菜品的销量是稀疏的——不是每个菜品每天都有销量而且受菜单调整、原材料供应的影响很大。我建议第一次做这个项目时主目标锁定在「门店日营收预测」上菜品销量作为附加分析。理由很简单营收数据连续、稳定适合用来建立完整的建模流程等这套流程跑通了再往菜品维度下沉替换标签并增加稀疏特征处理就好。2.3 评估指标回归任务的三个选择营收预测评估指标的选择是个容易被新手忽略的细节。常用的有 MAE平均绝对误差、RMSE均方根误差和 MAPE平均绝对百分比误差。对于餐饮营收我强烈建议同时看 MAE 和 MAPE。RMSE 对异常值敏感而餐饮营收的数据里天然存在大促日和节假日这些极端值。如果你的模型在普通日子的误差是 2000 块但国庆节那天差了 2 万RMSE 会被这个异常值拉得很难看而 MAPE 能告诉你模型在普通日子到底准不准。注意MAPE 在真实营收接近零的时候会爆炸如果你的餐饮数据里有门店装修歇业导致的零营收记录先过滤掉再算否则指标会失真。3. 从原始数据到特征矩阵清洗和特征工程的完整套路3.1 数据清洗的四个必做动作餐饮数据最常见的脏点集中在时间字段、金额字段和门店状态上。下面这段清洗代码是固定套路可以直接复用。import pandas as pd import numpy as np # 读取订单明细假定编码是gbk餐饮系统导出的Excel常常不是utf-8 df pd.read_csv(order_detail.csv, encodinggbk, parse_dates[order_time]) # 1. 过滤测试订单门店编号以T开头的都是测试数据 df df[~df[store_id].str.startswith(T, naFalse)] # 2. 处理金额字段的负值退款/取消订单的金额是负数 # 营收预测只统计有效订单退款单拉出来单独分析 df_refund df[df[pay_amount] 0].copy() df_valid df[df[pay_amount] 0].copy() # 3. 时间字段拆出日期和小时 df_valid[order_date] df_valid[order_time].dt.date df_valid[order_hour] df_valid[order_time].dt.hour # 4. 剔除单价异常离谱的数据单价高于1000或低于0.5的视为录入错误 df_valid df_valid[(df_valid[unit_price] 0.5) (df_valid[unit_price] 1000)]逻辑说明第一步的测试订单过滤经常被忽略餐饮系统上线初期会有大量用「测试门店」下出来的单子不滤掉会让后面每天的营收统计都带上噪声。第二步把退款单单独拎出来是因为退款是业务行为的一部分直接删掉会损失信息混在一起又污染营收——我建议在主表里过滤但单独保留退款明细用于特征工程。第四步的单价范围不是拍脑袋餐饮行业的客单价是有行业天花板的哪怕高端日料的人均也到不了单菜1000块以上这个阈值可以按你的数据分布调整。3.2 时间特征星期、节假日和营业时段拆分餐饮营收的周期性极强星期特征几乎是所有特征里最重要的一个。但只拆星期还不够节假日的前一天、后一天和当天行为模式完全不同。# 构建门店-日期粒度的营收明细 daily df_valid.groupby([store_id, order_date]).agg( revenue(pay_amount, sum), order_cnt(order_id, nunique), avg_discount(discount_amount, lambda x: x.sum() / max(df_valid.loc[x.index, origin_amount].sum(), 1)) ).reset_index() daily[order_date] pd.to_datetime(daily[order_date]) daily[weekday] daily[order_date].dt.weekday daily[is_weekend] daily[weekday].apply(lambda x: 1 if x 5 else 0) # 合并节假日表节假日表需要根据业务区域手动维护 # 字段包括 holiday_date, holiday_name, is_official_holiday holiday pd.read_csv(holiday_calendar.csv, parse_dates[holiday_date]) holiday[order_date] pd.to_datetime(holiday[holiday_date]) daily daily.merge(holiday[[order_date, holiday_name, is_official_holiday]], onorder_date, howleft) daily[is_holiday] daily[holiday_name].notna().astype(int) # 节假日前后一天的标记对营收预测特别有用 holiday_dates set(holiday[order_date]) daily[before_holiday] daily[order_date] pd.Timedelta(days1) daily[before_holiday] daily[before_holiday].isin(holiday_dates).astype(int)这里的逻辑说明is_official_holiday用来区分法定节假日和普通的周末因为两者对餐饮营收的拉动倍数完全不同。before_holiday这个特征往往出人意料地重要——很多人忽略了节前最后一天晚上聚餐的需求这个特征在模型里经常排进前三。节假日表需要自己维护不要试图从第三方库自动获取因为各地的农历节日、地方性假期并不一致手工维护最可靠。3.3 滞后特征和滚动统计餐饮预测的核心餐饮营收有非常强的自相关性昨天的营收和今天的营收高度相关。这就是滞后特征lag feature的基本逻辑。# 按门店分组做滞后特征注意一定要按日期排序 daily daily.sort_values([store_id, order_date]).reset_index(dropTrue) for lag in [1, 2, 3, 7, 14]: daily[frevenue_lag_{lag}] daily.groupby(store_id)[revenue].shift(lag) # 滚动窗口统计近7天均值、近28天均值、标准差 daily[revenue_roll_7_mean] daily.groupby(store_id)[revenue].transform( lambda x: x.shift(1).rolling(7, min_periods3).mean() ) daily[revenue_roll_28_mean] daily.groupby(store_id)[revenue].transform( lambda x: x.shift(1).rolling(28, min_periods7).mean() ) daily[revenue_roll_28_std] daily.groupby(store_id)[revenue].transform( lambda x: x.shift(1).rolling(28, min_periods7).std() ) # 滞后特征的缺失值填法用门店自身的中位数填充比填0更合理 lag_cols [c for c in daily.columns if lag in c or roll in c] for c in lag_cols: daily[c] daily.groupby(store_id)[c].transform(lambda x: x.fillna(x.median()))注意代码里的两个细节滚动统计里用了shift(1)目的是避免数据泄露——当我们在预测第 T 天的营收时第 T 天当天的数据不能参与特征计算必须整体平移一天。另一个细节是min_periods3新开业门店前几天的滚动窗口数据量不足这个参数允许用部分数据先算着等到数据攒够了再收敛到完整窗口。滞后天数的选择不是固定的。我一般先用 1、2、3、7 这四个尺度分别捕捉相邻日、周内模式和上周同期。14 天滞后对捕捉双周节奏比如发薪日效应有帮助但加了之后要和 7 天滞后对比一下如果特征重要性排名靠后就可以去掉。3.4 门店属性特征规模效应和商圈差异门店表里的维度特征也需要处理。面积、开业天数是连续值直接进模型没问题城市和商圈类型是类别特征需要编码。store_info pd.read_csv(store_info.csv) store_info[open_days] (pd.Timestamp(2024-12-31) - pd.to_datetime(store_info[open_date])).dt.days # 类别特征编码商圈类型这种低基数类别用one-hot门店ID用目标编码 store_info pd.get_dummies(store_info, columns[biz_type], prefixbiz) # 门店ID做目标编码用历史营收均值替代门店ID本身 store_target daily.groupby(store_id)[revenue].mean().rename(store_revenue_avg).reset_index() store_info store_info.merge(store_target, onstore_id, howleft) # 门店面积和营收规模经常有非线性关系做一个面积的对数变换 store_info[log_area] np.log1p(store_info[area])这里要说明的是门店ID 如果直接做整数编码扔给树模型模型会把它当成有序变量产生「门店 37 比门店 36 好」这种毫无意义的推断。目标编码是更稳妥的做法——用历史营收均值作为门店 ID 的替代既保留了门店间的差异又不引入虚假的顺序关系。但目标编码有标签泄露风险后面避坑章节会展开讲。4. 建模与预测从基线模型到调参落地的完整路径4.1 数据集切分的时序陷阱餐饮数据是典型的时间序列切分训练集和测试集时绝对不能用train_test_split默认的随机切分模式。随机切分会让模型看到未来的数据在测试集上的表现虚高得离谱一到真实业务场景立刻翻车。# 按时间顺序切分前80%时间的数据训练后20%时间的数据测试 daily daily.sort_values(order_date).reset_index(dropTrue) cutoff int(len(daily) * 0.8) train daily.iloc[:cutoff].copy() test daily.iloc[cutoff:].copy() # 还要再切一块验证集用来调参不能直接拿测试集调 cutoff_val int(len(train) * 0.85) train_set train.iloc[:cutoff_val].copy() val_set train.iloc[cutoff_val:].copy() print(f训练集时间范围: {train_set[order_date].min()} ~ {train_set[order_date].max()}) print(f验证集时间范围: {val_set[order_date].min()} ~ {val_set[order_date].max()})这里强调两件事第一验证集和测试集都要在时间上严格晚于训练集第二不能拿测试集反复调参否则测试集就变成了验证集最终评估结果不再客观。餐饮数据里偶尔会有门店临时关店一两天导致的空缺这种缺失不用填充树模型能自动处理。4.2 基线模型先跑通决策树回归上来直接上深度学习是大忌。餐饮营收预测的样本量通常只有几千到几万条树模型是这个场景的绝对主力。先用一个最简单的决策树建立基线确认流程通畅再换成集成模型。from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error features [weekday, is_weekend, is_holiday, before_holiday, revenue_lag_1, revenue_lag_2, revenue_lag_3, revenue_lag_7, revenue_roll_7_mean, revenue_roll_28_mean, revenue_roll_28_std, open_days, log_area, store_revenue_avg, avg_discount] X_train train_set[features] y_train train_set[revenue] X_val val_set[features] y_val val_set[revenue] model DecisionTreeRegressor(max_depth6, min_samples_leaf20, random_state42) model.fit(X_train, y_train) val_pred model.predict(X_val) print(fMAE: {mean_absolute_error(y_val, val_pred):.0f}) print(fMAPE: {mean_absolute_percentage_error(y_val, val_pred)*100:.1f}%)max_depth6和min_samples_leaf20这个组合是我常用的起步配置。深度限制在 6 主要是防止单棵树过深导致过拟合min_samples_leaf20保证叶子节点至少覆盖 20 个样本让预测结果更平滑。这个基线的 MAE 如果已经到 15% 以内说明特征工程做得不错如果 MAPE 超过 25%先停下来检查特征而不是急着换模型。4.3 主力模型LightGBM 的参数调优与训练决策树基线确认流程没问题之后换成 LightGBM 是餐饮数据项目最常见的选择。它训练快、自带处理缺失值能力、对异常值稳健而且不需要做特征标准化。import lightgbm as lgb # 构建lgb原生数据集指定类别特征 lgb_train lgb.Dataset(X_train, y_train) lgb_val lgb.Dataset(X_val, y_val, referencelgb_train) params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 31, max_depth: 7, min_data_in_leaf: 30, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1, seed: 42 } model lgb.train( params, lgb_train, num_boost_round1000, valid_sets[lgb_val], callbacks[lgb.early_stopping(stopping_rounds100), lgb.log_evaluation(100)] ) val_pred model.predict(X_val, num_iterationmodel.best_iteration) print(fLightGBM MAE: {mean_absolute_error(y_val, val_pred):.0f}) print(fLightGBM MAPE: {mean_absolute_percentage_error(y_val, val_pred)*100:.1f}%)参数选择有明确的业务思量。metric用mae而不是默认的l2是因为餐饮营收预测里我们希望每个门店的误差都尽可能均衡MAE 对异常大促日不会过度惩罚。feature_fraction0.8和bagging_fraction0.8是双随机机制防止树与树之间相关性过高。min_data_in_leaf30是餐饮数据场景下很关键的参数它可以防止模型在样本量小的冷门门店上硬学出离谱的预测值。4.4 特征重要性解读与迭代模型跑通之后一定要看特征重要性排序。这一步的意义不是让你发一张漂亮的图而是反过来验证你对业务的理解。import lightgbm import pandas as pd importance_df pd.DataFrame({ feature: model.feature_name(), importance: model.feature_importance(importance_typegain) }).sort_values(importance, ascendingFalse) print(importance_df.head(10))importance_typegain表示按特征在树分裂中带来的总信息增益排序这比默认的split次数更能反映特征的预测价值。餐饮营收场景下正常排名应该是revenue_lag_1和revenue_roll_7_mean排前两名节假日特征紧随其后门店属性排在中间。如果你的模型里store_revenue_avg一骑绝尘其他特征都排在后面说明模型基本退化成按门店历史均值预测其他特征没有真正起作用——这时候要检查是不是目标编码的标签泄露问题后面避坑章节会专门讲。5. 餐饮数据预测的五个高频坑现象、原因和解决方案5.1 随机切分导致的数据泄露测试集 MAPE 虚报到 5% 以内现象用train_test_split默认随机模式切分数据LightGBM 在测试集上的 MAPE 能做到 5% 以内看起来可以上线了。结果一到真实业务里做滚动预测MAPE 直接飙到 30% 以上。原因随机切分让训练集里混入了未来时间的数据。餐饮营收的滞后特征和滚动特征天然携带时间信息模型实际上是在用「未来的邻居」做预测测试集的真实性完全不存在。解决严格按时间顺序切分训练集、验证集、测试集的时间窗口必须顺次递增。另外滚动预测时要注意——用第 T-1 天的数据预测第 T 天然后等第 T 天数据真正到了之后再预测第 T1 天预测的滞后特征永远是上一轮的真实值而不是上一轮的预测值。5.2 目标编码的标签泄露门店特征排第一且遥遥领先现象store_revenue_avg特征的重要性远超其他特征模型预测值基本等于该门店历史营收的均值加一点波动。原因目标编码用的是全量历史数据计算的均值包括测试期时间范围内的营收数据。当你用整段数据算均值再切分测试集的信息已经提前泄漏到了训练集里。解决目标编码必须在训练集内部计算并且只能用训练集时间之前的均值。做法是先在训练集上按照时间排序计算每个门店在训练集范围内的营收均值然后把测试集门店映射到这个均值上。代码上要保证store_target daily[daily[order_date] cutoff].groupby(store_id)[revenue].mean()只取切分之前的数据来编码。5.3 节假日特征的绝对化编码把节日当天和前一天的差异抹平了现象模型在普通周末的预测误差稳定但每逢节前一天大排长队的时候预测值明显偏低误差比平时大两倍。原因只用is_holiday一个 0/1 特征把「节日当天」和「节前一天」混为一谈。餐饮行业里节前一天的营收往往比节日当天还高——大家过节前聚餐节日当天反而都出城了。解决拆成is_holiday、before_holiday、after_holiday三个特征分别标记节日当天、前一天和后一天。如果数据跨多个年份还要加一个holiday_name的目标编码特征因为国庆节和中秋节对餐饮的拉动完全不是一个量级。5.4 新门店和冷启动门店的预测偏差大模型给出离谱的负值或超低值现象开业不到一个月的门店预测营收比实际低了一倍。因为滞后特征和滚动特征全是空值即使填充中位数这些特征对新门店也没有信息量。原因滞后特征和滚动特征基于门店自身的历史数据新门店没有历史特征值被统一填充成中位数后模型只剩下门店属性特征可以参考预测自然不靠谱。解决第一构建特征时给「冷启动」单独加一个标记特征is_new_store让模型学习到新门店和成熟门店的差异。第二为冷启动门店单独建立模型只用车龄小于 30 天的门店数据训练特征只用门店属性和日期属性不用滞后类特征。冷启动预测的误差大是合理的关键是要让模型知道自己在冷启动场景而不是硬用成熟门店的模式去套。5.5 外卖订单和堂食订单混在一起建模午高峰的预测误差特别大现象整体 MAE 看着还行但按小时段拆开评估发现午餐时段11:00-13:00的预测误差是晚餐时段的两倍。原因外卖订单集中在午餐时段而外卖的营收波动受天气、平台活动、骑手运力影响和堂食的行为模式不同。混合建模时模型学到的是一个平均规律两头都照顾不好。解决订单数据增加渠道维度之后把「外卖订单占比」做成聚合特征——每天每个门店的外卖订单数除以总订单数。外卖占比超过 60% 的门店和低于 20% 的门店营收行为模式差异显著这个特征能帮模型自动区分并分别拟合。更进一步的做法是拆成两个模型分别预测堂食和外卖营收再相加得到总营收。6. 让预测真正落地滚动重训、分位数预测与闭环验证前面的流程跑通之后项目还停在一个「离线实验」的阶段。要让它真正在餐饮企业里产生价值最后这一步是把静态模型改造成滚动更新的状态。第一步是滚动重训。餐饮数据有季节性漂移——冬季的火锅旺季、夏季的烧烤旺季、开学季的商圈流量变化模型的特征分布会随时间缓慢偏移。我养成的习惯是每周一用截至上周日的全量历史数据重训一次模型并保留早停机制评估本周的预测窗口。这样可以保证模型永远是用「最新鲜」的数据训练出来的而不是盯着半年前的分布。LightGBM 训练一轮通常只要几十秒成本完全可接受。第二步是输出分位数预测而不是单点预测。点预测告诉你明天营收是 5 万块但对备货决策没有指导意义。改用objective: quantile分别训练 alpha0.2、0.5、0.8 三个分位数模型你就能告诉店长营收有 80% 的概率落在 4.2 万到 5.8 万之间。库存按 20% 分位备货负责控损耗按 80% 分位备货负责保供应两个场景用两套数字。最后是闭环验证。预测模型上线后我每个月会做一次回测把过去 30 天的实际营收和当时的预测值放在一起算误差按门店和星期维度拆开看哪个门店、哪个星期几的误差在持续恶化。这一步的本质是在监控数据漂移而不是监控模型本身。我曾经遇到过一家门店因为周边修路导致客流骤降模型预测持续偏高靠月度回测在第二周就发现了异常及时干预调整了备货策略。做这类餐饮数据项目最深的教训是特征工程的价值永远大于模型调参。把门店属性、时间周期、营销活动这些业务信息处理干净LightGBM 的默认参数就能交一份够用的答卷反过来特征一团糟的时候调参就是玄学。把这套流程完整走一遍你对餐饮行业的理解深度会远超那些只会在 Kaggle 上跑排行榜的人。希望帮到你。本文还有配套的精品资源点击获取