简介:一份针对2017年BDCI“小超市供销存管理优化”赛题的完整Python源代码包,主要面向数据分析与机器学习初学者、竞赛参与者以及需要供应链/库存预测方案的开发者。代码围绕小超市销售与库存数据,演示从特征提取、数据清洗、模型选择、训练评估到预测优化的完整流程,并提供模型解释与迭代思路。压缩包共20个文件、仅1.22MB,组织紧凑:11个csv文件存放原始数据、预测结果及中间数据,便于对照实验;5个py脚本涵盖model1、model2、model3三种模型实现以及输出和预测模块,可直接运行复现赛题方案;2个docx分别为项目报告和下载者使用说明,1个pptx为项目汇报,另有txt运行顺序说明,结构清晰。目前已有309人学习下载。通过该资源,可以拿到一套可复现的BDCI赛题代码,理解不同机器学习模型在小超市供销存场景中的建模细节,并迁移到类似业务预测任务中。
1. 2017BDCI 小超市供销存管理优化:这个赛题到底在优化什么
2017BDCI 小超市供销存管理优化,本质上不是让你做一个纯销量预测,而是给你一家模拟小超市的销售、采购、库存流水,让你在有限成本约束下把“进、销、存”三个环节的决策串起来。它适合正在学机器学习但想碰真实业务约束的人,也适合做供应链方向但缺少落地场景的工程师。赛题的价值在于,预测只是手段,真正决定排名的是一套能解释、能回测、能扛住促销和季节性波动的决策策略。读完这篇,你能从原始表一路做到可执行的补货建议。
2. 读懂赛题数据:供销存三个表怎么关联,预测目标怎么定
2.1 数据结构与字段:销售、采购、库存的核心表
先别急着建模,把三张表的关系理清楚。常见做法是拿到三个 CSV:销售表一般包含销售日期、商品编码、销售数量、销售金额;采购表包含采购日期、商品编码、采购数量、采购单价;库存表包含库存日期、商品编码、库存数量。也许还有商品信息表,包含商品类别、进价、售价。我一般先用 pandas 读进来,做一下字段类型检查和空值统计。以下代码是第一步:
import pandas as pd sales = pd.read_csv('sales.csv', parse_dates=['sale_date']) purchase = pd.read_csv('purchase.csv', parse_dates=['purchase_date']) inventory = pd.read_csv('inventory.csv', parse_dates=['inv_date']) print(sales.info()) print(purchase.info()) print(inventory.info()) print(sales.isnull().sum(), purchase.isnull().sum(), inventory.isnull().sum())逻辑说明:parse_dates 把日期列转成 datetime,方便后面做时间窗口聚合。先看 info 能发现列名是否对齐、有没有缺失。注意,实际赛题文件可能列名不同,比如用“销售时间”“商品ID”,你需要根据字典做映射。这里我建议不要直接用原列名,先统一成英文小写,后面写特征工程不折腾。
参数说明:read_csv 里的 sep 参数默认是逗号,如果文件是制表符或分号,记得改成 sep='\t' 或 sep=';'。另外一个坑是某些字段的值里含逗号,比如商品名称带逗号,会导致列错位,用 quoting=1 能缓解。不过赛题数据一般是规范的,这个问题主要在你自己爬数据时遇到。
接下来要按商品和时间对齐三张表。销售表每天有记录,但采购和库存不是每天都有,尤其是采购可能几天一次。我常用的方式是把采购表和库存表按日期重采样到每一天,再前向填充。
def resample_daily(df, date_col, value_col): df = df.set_index(date_col) # 按天聚合,没有记录的天数不填充 daily = df[value_col].resample('D').sum() # 采购数量是累计发生,库存数量是时点值,这里先统一做 ffill return daily purchase_daily = resample_daily(purchase, 'purchase_date', 'purchase_qty').ffill() inventory_daily = resample_daily(inventory, 'inv_date', 'stock_qty').ffill()逻辑说明:resample('D').sum() 把同一日的多条记录合并。ffill 表示当天没有采购记录时,沿用最近一次已知值,这对库存和采购日期这种稀疏事件适用。但要注意,时点库存直接用 ffill 会忽略当天销售导致的库存减少,需要后续结合销售修正。
这里就引入了供销存联动的第一个选择:你要建的是“日粒度”特征还是“周粒度”特征。赛题给的预测窗口通常是一周或一个月,如果你的特征也是日粒度,模型要自己学滞后关系,训练慢而且容易过拟合。我一般先做周聚合,把销售数量、采购数量、库存均值按周合并,预测下一周的总销量。这样可以明显降低噪声,也让决策输出更贴近实际补货频率。小超市通常每周订一到两次货,按周预测更合理。
2.2 目标函数与评价指标:不只是预测销量
赛题的评价指标决定了你怎么设计标签。很多新手上来就套 MAE 或 RMSE,但供销存优化里,缺货损失和库存积压成本不对等。如果赛题要求你输出补货量,后台会用模拟器跑一段时间,统计缺货次数、库存周转、利润率等综合得分。这时候你如果只优化销量预测误差,很可能在得分上翻车。
常见做法是把问题拆成两段:先预测未来 n 天的销量分布,再基于分布做库存决策。如果你只能拿到评分脚本,最好在本地做一个模拟器,用历史数据回测你的决策策略。模拟器不一定复杂,核心是把每天的销量、期初库存、采购到货量按顺序跑一遍,计算期末库存和缺货量。下面给一个简化版的回测函数:
def simulate(initial_stock, sales_series, order_plan, lead_time=1): stock = initial_stock shortage = 0 total_order = 0 for t in range(len(sales_series)): # 到货 if t >= lead_time: stock += order_plan.get(t - lead_time, 0) total_order += order_plan.get(t - lead_time, 0) # 销售 sale = sales_series[t] if stock >= sale: stock -= sale else: shortage += sale - stock stock = 0 return shortage, total_order参数说明:lead_time 是从下单到到货的天数,小超市可能是一天,大超市可能是三天。order_plan 是一个字典,key 是下单日,value 是下单量。这个函数没有考虑保质期和退货,但作为基线够了。你可以在本地用它对比不同策略,得出一个保守的缺货惩罚权重。
评价指标公式虽然在赛题说明里会有,但我建议你自己定义业务指标:缺货率 = 缺货天数 / 总销售天数;库存周转率 = 总销售成本 / 平均库存成本。这两个指标互相矛盾,你需要画 Pareto 图找到平衡点。很多获奖方案不是预测最准的,而是在缺货率和周转率之间找到了合理的折中。
2.3 把赛题转成监督学习:样本构建与标签设计
之所以这一节单独讲,是因为我见过太多人直接拿“商品-日期”当样本,预测当天销量,然后发现模型精度上不去。问题在于日期特征太稀疏,尤其是小超市,单个商品可能经常连续几天销量为 0。正确的做法是构建一个周期性样本:用过去 4 周的窗口特征预测未来 1 周的总销量。
def build_samples(sales, product_list, start_date, end_date): records = [] for pid in product_list: for d in pd.date_range(start_date, end_date, freq='W-MON'): # 历史窗口:d 往前推 4 周 hist = sales[(sales.product_id == pid) & (sales.sale_date < d) & (sales.sale_date >= d - pd.Timedelta(weeks=4))] # 未来窗口:d 到 d+6 天 future = sales[(sales.product_id == pid) & (sales.sale_date >= d) & (sales.sale_date < d + pd.Timedelta(weeks=1))] if len(future) > 0: records.append({ 'product_id': pid, 'week_start': d, 'hist_sales': hist.sales_qty.sum(), 'target': future.sales_qty.sum() }) return pd.DataFrame(records)逻辑说明:用 freq='W-MON' 生成每周一作为周起始日。历史窗口固定为 4 周,目标为未来 7 天销量。注意条件 future 样本只要未来至少有一天有记录,就把它纳入。这个设计把问题从“预测逐日销量”变成“预测周销量”,噪声更小,也符合补货决策需求。
这里有一个数据泄漏的隐患:历史窗口应严格小于目标窗口的起始日,代码里用了 < d,不会把当周数据包含进去,这个写对了。如果你不小心写成 <= d,那么未来销量的一部分被当成了历史特征,回测分数虚高,真实场景彻底翻车。后面第 5 章我会专门展开。
参数说明:窗口长度 4 周不是绝对的,你可以用 6 周或者 8 周。小超市受节假日影响大,如果用 4 周,碰到春节或国庆,历史均值可能被拉高。我一般会同时加一个“去年同期窗口”特征,因为年度季节性在小超市更明显。但赛题可能只给了 1 年数据,这时只能退而求其次,用最近 8 周加权平均。
3. 用 Python 搭建供销存优化基线:特征工程与模型选型
3.1 特征工程:时间、商品、价格、节假日怎么组合
销量预测的特征工程优先级:时间窗口特征 > 价格和促销特征 > 商品静态特征 > 外部节假日特征。很多人一上来就堆了上百个特征,最后 LightGBM 训出来效果一般,因为特征之间多重共线性太强,而且小超市数据量不大,容易过拟合。
我先给一个最低可行的特征集合。时间特征包括:周数、月份、星期几、是否为月初(可能影响发工资后的消费)。商品特征包括:商品类别、进价、售价、毛利率。历史特征包括:过去 1/2/4 周销量、过去 4 周销量均值、过去 4 周销量标准差、过去 4 周最大销量、过去 4 周零销售天数。价格特征包括:当前售价比前一周售价的变化量、是否降价。下面代码演示如何生成这些特征:
def add_features(df, sales, price_history): # 时间特征 df['week_of_year'] = df['week_start'].dt.isocalendar().week df['month'] = df['week_start'].dt.month df['is_month_start'] = df['week_start'].dt.day <= 4 # 历史销量特征 df['hist_mean'] = df.groupby('product_id')['hist_sales'].transform('mean') df['hist_std'] = df.groupby('product_id')['hist_sales'].transform('std') df['hist_max'] = df.groupby('product_id')['hist_sales'].transform('max') df['zero_days'] = df['hist_sales'].apply(lambda x: (x == 0).sum()) # 价格变化 df = df.merge(price_history, on=['product_id', 'week_start'], how='left') df['price_change'] = df['current_price'] - df['prev_price'] return df逻辑说明:hist_sales 在上一节构建样本时是 4 周销量总和,这里不能再做 groupby,因为它已经是聚合结果。实际上 hist_sales 是一个具体的值,你需要保留原始每日销售再进行窗口统计。示例代码里 transform('mean') 是按商品颗粒度求均值,意义不大。更合理的做法是在构建样本时就用每天记录算出多个窗口特征。为了避免误导,我把上面的代码当做一个演示结构,真正实现时要为每个商品计算滑动窗口。
我建议用合并的方式避免循环。先把销售表按周聚合,再对每个商品做 shift。核心逻辑是:
weekly_sales = sales.groupby(['product_id', 'week_start'])['sales_qty'].sum().reset_index() weekly_sales = weekly_sales.sort_values(['product_id', 'week_start']) weekly_sales['prev_1w'] = weekly_sales.groupby('product_id')['sales_qty'].shift(1) weekly_sales['prev_2w'] = weekly_sales.groupby('product_id')['sales_qty'].shift(2) weekly_sales['prev_4w_mean'] = ( weekly_sales.groupby('product_id')['sales_qty'] .transform(lambda x: x.rolling(4, min_periods=1).mean().shift(1)) )参数说明:shift(1) 表示取上一周的销量,shift 不会引入未来数据,因为它是按行顺序取前一行。rolling(4).mean().shift(1) 表示过去 4 周(不包括本周)的均值,min_periods=1 让数据不足 4 周时也给出部分均值,避免产生大量 NaN。如果你直接用 rolling 而不 shift,会把当周销量纳入均值,导致标签泄漏。
节假日特征在赛题里比较微妙。有些赛题明确禁止使用节假日信息,因为测试集可能覆盖到没有节假日的区间;即便不禁止,也需要小心:节假日标签如果是从未来日期表里查出来的,本身就是合法的外部特征,但如果你的特征里包含节假日导致的销量脉冲,模型会把“节假日”和“销量激增”强关联,导致节后预测过高。我通常只加入一个“是否节假日”的布尔特征,不加入“距离节假日还有几天”这种连续特征,除非你能确定模型有足够数据学习。
3.2 模型选型:从线性回归到 LightGBM 的取舍
小超市数据量不大,往往只有几十个商品、半年的日记录。这种情况下,深度模型容易过拟合,线性回归和树模型更稳。我推荐按这个顺序尝试:岭回归(双对数变换后)→ 梯度提升树 → LightGBM。为什么不是随机森林优先?随机森林对连续特征的边界处理不如 GBDT,而且销量分布长尾,RF 在极端值上表现差。
具体到 2017 年的赛题环境,当时最常用的是 LightGBM。它有三个理由适合这里:一、支持 categorical 特征(商品编码可以不用 one-hot,直接用 category 类型);二、叶节点分裂对缺失值有内部处理,历史特征缺失不用你填;三、训练快,可以快速试特征组合。如果用 sklearn 的 GradientBoostingRegressor,在小数据上也完全够用,但调参时间更长。
我建议把模型封装成一个函数,方便对比。下面是一段 LightGBM 训练代码:
import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit def train_lgb(df, features, target='target'): # data = df.dropna(subset=[target]) # 按 week_start 排序,保证切分不泄漏 data = df.sort_values('week_start').reset_index(drop=True) X = data[features] y = data[target] tscv = TimeSeriesSplit(n_splits=4) for fold, (tr_idx, va_idx) in enumerate(tscv.split(X)): X_tr, X_va = X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx] model = lgb.LGBMRegressor( objective='regression', n_estimators=200, learning_rate=0.05, num_leaves=31, random_state=42, n_jobs=-1 ) model.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], eval_metric='l1', callbacks=[lgb.early_stopping(20), lgb.log_evaluation(0)]) return model, tscv逻辑说明:TimeSeriesSplit 是 sklearn 自带的时间序列交叉验证,它按照顺序切训练集和验证集,不会打乱数据。相比随机 KFold,它避免了未来样本进入训练集的问题。early_stopping 用验证集上的 l1 误差决定最佳迭代轮数。注意这里 final model 是在最后一个 fold 上训练的,想要更稳可以保留每个 fold 的模型做集成。
参数说明:num_leaves=31 是 LightGBM 默认值,在小数据上可以降到 15 防止过拟合。learning_rate=0.05 配合 200 棵树足够,如果数据量增加可以调整到 0.01 并增加 n_estimators。model.fit 里 eval_set 只用于早停,不会影响最终模型的参数。
如果你不想引入 LightGBM 的额外依赖,sklearn 的 HistGradientBoostingRegressor 也能给出近似效果。它同样支持 category 特征,并且训练快。不过它在 early stopping 上的接口没有 LightGBM 灵活,我个人还是倾向用 LightGBM。问为什么不用 XGBoost?在小数据上两者精度差别不大,但 LightGBM 处理类别特征更方便,代码更简洁。
3.3 基线代码框架:训练、验证、预测一条龙
现在我把前面的步骤串成一条最小可运行流程。这个流程的输入是 sales、purchase、inventory 三张表,输出是未来一周每个商品的预测销量和补货建议。我先给一个可运行脚本的骨架,然后讲哪些地方要根据数据调整。
def main(): # 1. 读取与预处理 sales, purchase, inventory = load_data() # 2. 构建周样本 df = build_samples(sales, sales.product_id.unique(), start_date='2017-07-01', end_date='2017-12-31') # 3. 特征工程 df = add_features(df, sales) # 4. 训练与交叉验证 features = ['week_of_year', 'month', 'is_month_start', 'prev_1w', 'prev_2w', 'prev_4w_mean'] model, cv = train_lgb(df, features) # 5. 预测未来一周 future_weeks = pd.date_range('2018-01-01', periods=4, freq='W-MON') future_df = build_future_samples(sales, product_list, future_weeks) future_df = add_features(future_df, sales) future_df['pred'] = model.predict(future_df[features]) return future_df[['product_id', 'week_start', 'pred']]逻辑说明:这个骨架把数据处理和模型解耦。其中 build_future_samples 和 build_samples 的区别是,未来样本没有 target,只有历史特征。你需要保证特征列名完全一致,否则 predict 会报错。
参数说明:start_date 和 end_date 要根据数据范围调整。训练集的截止时间必须早于你想预测的期间,否则时间穿越。我这里写 2017 年是一个示例,实际赛题数据可能更早,你需要根据给定的训练集决定。
运行之后,第一件事不是看预测精度,而是看预测值分布。小超市销量经常是多数商品销量低、少数商品销量高,直接用回归模型容易预测出负数。处理方式:对 target 做 log1p 变换,训练时用 log1p(target),预测后 expm1 还原。下面是改法:
model.fit(X_tr, np.log1p(y_tr), eval_set=[(X_va, np.log1p(y_va))], eval_metric='l1') pred = np.expm1(model.predict(X))说明:log1p 对零值友好,避免 log0。预测结果还原后不会是负数。但注意,如果你的真实销量是离散的 0、1、2 这种,预测值 25.7 本身无法直接使用,需要结合库存策略做取整。取整不是四舍五入,而是向上取整,因为缺货损失通常大于过量库存成本。这个问题我下一章讲。
4. 库存与采购策略联动:预测出来之后怎么决策
4.1 安全库存与订货点:参数怎么定
预测出了未来一周销量,不等于你要把预测值直接作为采购量。真实场景里有供应商配送周期、有需求波动、有库存资金成本,所以需要安全库存作为缓冲。安全库存常用公式是 Z × σ_d × sqrt(L),其中 Z 是服务水平对应的标准正态分位数,σ_d 是日销量的标准差,L 是补货提前期的天数。这个公式来自库存管理经典模型,在小超市场景下可以简化。
一般我会先算每个商品的周销量均值和标准差,然后定义服务水平。小超市服务水平取 0.90 到 0.95 比较合理,太高(0.99)会导致大量积压,太低(0.85)又会频繁缺货。下面代码给出参数计算:
def safety_stock(mean_daily, std_daily, lead_time_days, service_level=0.95): from scipy.stats import norm z = norm.ppf(service_level) return z * std_daily * np.sqrt(lead_time_days)逻辑说明:如果输入的是周销量,需要先除以 7 得到日均。公式假设需求独立且服从正态分布,小超市未必满足,但作为基线够用。如果实际数据偏态明显,可以改用分位数法:取过去 4 周销量的 90% 分位数减去均值作为安全库存。
参数说明:lead_time_days 在采购表中可以通过“下单日期”和“到货日期”之差计算。如果数据里没有两个日期,只能用一个小常数,比如 1 天或 3 天。这里不要拍脑袋,最好统计采购日期的间隔,取中位数。如果采购表只有采购日期,没有到货日期,那就没法直接算,只能用固定值并说明假设。
订单点的定义是:当库存下降到某个阈值时,触发补货。阈值 = 提前期内的预期需求 + 安全库存。代码逻辑:
reorder_point = mean_daily * lead_time_days + safety_stock这句话非常关键。如果你直接用预测销量当订货点,会忽略提前期内的消耗。比如提前期 3 天,日销 10 件,安全库存 5 件,订货点就是 35 件。库存还剩 20 件时看起来还有货,但等 3 天后到货时已经缺货了。这就是为什么很多预测模型好用,但补货决策依然翻车的原因。
4.2 补货计划生成:让预测结果变成可执行的采购单
有了订货点,我们还要定期生成补货计划。小超市常用周期性补货(比如每周一盘点一次),所以这里采用“定期补货”而非连续补货。流程是:每周初查看当前库存,如果库存低于订货点,则下单采购一个固定数量,目标是让补货后的库存达到目标库存水平。
目标库存水平 = 订货周期内需求 + 提前期内需求 + 安全库存。用公式表达就是 (T + L) × 日均销量 + 安全库存。下面代码生成采购单:
def generate_order(current_stock, forecast_sales, lead_time_days, review_days=7, service_level=0.95): # 对预测销量求均值与标准差 mean_daily = forecast_sales / review_days std_daily = forecast_sales.std() / review_days if hasattr(forecast_sales, 'std') else mean_daily * 0.3 ss = safety_stock(mean_daily, std_daily, lead_time_days, service_level) target_level = (review_days + lead_time_days) * mean_daily + ss order_qty = max(0, target_level - current_stock) return order_qty逻辑说明:target_level 是补货后的期望库存,order_qty 是差额。如果 current_stock 已经高于 target_level,则不补货。这里用 forecast_sales 除以 review_days 得到日均需求。如果 forecast_sales 是一个单独数字,没有 std 属性,就手动假设一个变异系数 0.3,这是经验值,你可以改成从历史数据里算出的真实变异系数。
参数说明:review_days=7 表示每周补一次。如果门店要求最低起订量,还需要加一个判断:当 order_qty > 0 且低于起订量时,直接按起订量下单。这个在代码里加一行就行,但很多人忘掉,导致生成一堆小订单,物流成本高。
补货计划直接输出的是一张采购单,至少包含商品编码、建议采购数量、预计到货日期、当前库存、预测销量。我一般还会加一个“紧急程度”字段,用预测销量与当前库存的比值分档,方便采购员优先处理。
4.3 模拟回测:用历史数据验证决策效果
预测能不能用,必须回到模拟器里验证。前面第 2 章给了一个简化 simulate 函数,这节我把它扩展成带周期性补货的回测流程,用来对比不同策略。
def backtest(df_sales, initial_stock, strategy, lead_time=1, review_days=7): stock = initial_stock.copy() records = [] for d in pd.date_range(df_sales.sale_date.min(), df_sales.sale_date.max(), freq='D'): # 每天统计销量 date = d.date() if d.weekday() % review_days == 0: order = strategy(d, stock) # 记录下单,在 lead_time 天后到货 # 处理到货、扣减库存、记录缺货 # 省略具体实现 return pd.DataFrame(records)逻辑说明:这个骨架只是为了演示回测的结构,完整实现需要考虑一次订多日、提前期错位、节假日停运。你可以在本地把日粒度跑通,然后对比两个策略:A 策略用预测销量作为补货量;B 策略用目标库存水平补货。结果通常 B 策略在缺货率上更优,因为 B 策略考虑了提前期和安全库存。
回测指标建议用三个:缺货率(缺货天数 / 总天数)、平均库存成本(平均库存量 × 商品成本)、总采购额。你需要在三者之间画折衷曲线。不要只用“预测误差”评价策略,这样会错过很多业务细节。
5. 供销存优化常见问题与避坑指南:5 个踩坑记录
这一章把我在模拟项目 X 和类似赛题里反复踩过的 5 个坑写出来,每条按现象、原因、解决来讲。希望你能跳过这些坑。
5.1 数据泄漏:用了未来信息而不自知
现象:交叉验证分数很高,但提交到隐藏测试集后分数骤降。检查特征时发现,某个特征在时间维度上已经包含了未来的销量信息。
原因:最常见的来源有两个。一个是构建样本时使用了包含当周的 rolling 窗口,rolling(4)默认包含当前行,导致特征里混入目标;另一个是从全部数据集做标准化时用了未来数据的均值与方差,比如用全年的均值去归一化训练集前几个月的样本。
解决:窗口特征统一使用shift(1),确保特征只依赖过去。标准化或归一化必须在每个时间折折内用训练子集的统计量进行,不能全局 fit。检验方法:把特征矩阵按时间排序,随机选择一行,人工确认该行所有特征的时间戳都早于标签时间。
5.2 销量为 0 的样本处理:别让缺货掩盖真实需求
现象:某个商品连续 3 天库存为 0,销量表里这 3 天就是 0。模型学到的是“库存 0 → 销量 0”,看起来很准,实际是因为根本没货可卖。
原因:销售表记录的是实际成交量,不是需求量。当库存为 0 时,真实需求未被满足,表里不会出现。如果直接用这份数据训练,模型会把缺货当正常状态,预测值系统性偏低。
解决:在训练集中剔除库存为 0 的日子,或者用前几周销量中位数估计潜在需求,再或者仅预测有库存时的销量。如果赛题数据里库存表能给你当天的库存量,优先用库存 > 0 的样本。如果拿不到,至少将连续零销量样本单独建标签,做二分类(是否缺货)再预测数量。
5.3 价格变动与促销的混淆:特征时分清因果
现象:加入了“是否促销”特征后,模型在促销期间销量预测明显偏高,但实际促销只在某些商品上生效,模型却给所有商品都加上了促销增量。
原因:促销特征与商品特征发生了交互,而模型没有足够数据区分“该商品促销”和“全店促销”。小超市促销通常是单商品或相互关联的几款商品,不是全店统一。如果特征里只有一个全局促销标志,模型会把它当全局效应。
解决:把所有促销相关特征改造成商品粒度:该商品是否降价、降价幅度、是否参与买一送一。同时加入促销前一周的基础销量作为对照。如果数据不够,直接删除促销特征,让模型从历史价格变化斜率和销量变化里自行学相关性,比强行塞一个脏特征更稳。
5.4 时间序列交叉验证的切法:不能随机 shuffle
现象:用KFold(shuffle=True)验证模型误差,得到的指标比 TimeSeriesSplit 好很多,于是你很高兴地提交,结果测试集分数变差。
原因:随机 KFold 会把未来样本混进训练集,模型“偷看”了未来销量趋势,验证时占了便宜。真实数据是时序的,必须保序切分。
解决:一律使用TimeSeriesSplit或者手动按日期切分。训练集永远在验证集之前。切分时还要注意“gap”:当特征窗口长度为 4 周时,训练集最后 4 周的数据会与验证集起始数据窗口重叠,可能导致轻微泄漏。最佳实践是在训练集和验证集之间留出至少一个特征窗口长度的间隙,比如预测目标周之前 4 周的数据都从训练集剔除。
5.5 库存成本参数拍脑袋:决策结果的敏感度
现象:同一套预测模型,把安全库存系数从 0.9 改成 0.95,缺货率下降 10%,但库存成本上升 25%。你觉得 0.95 更好,却说不清为什么。
原因:决策策略里的服务水平直接影响补货量。这个参数不是模型调出来的,而是业务权衡。如果不做敏感性分析,任何结论都站不住脚。
解决:跑一组服务水平扫描实验,比如 0.85、0.90、0.95、0.99,分别记录缺货率和库存成本,画出曲线。在曲线上找拐点,通常 0.92 或 0.95 附近是性价比最高的区域。这个实验用第 4 章的 backtest 函数就能做,只需要循环几个参数值,几分钟就能出图。以后呈现方案的时候,附上这张曲线比任何预测精度指标都有说服力。
6. 把方案落地成可交付代码的进阶技巧:模块化配置与结果留痕
前面几章的代码能跑通一条基线,但交付给业务方或提交给评委时,还差两个基本功:模块化配置和结果留痕。开发时你可能有十几个参数散布在代码里,一旦要复盘,根本找不到当时用了哪些参数。我习惯把所有可调参数收敛到一个 yaml 或 json 配置里,训练脚本只读取配置,不写死参数。举个例子:
import yaml with open('config.yaml') as f: config = yaml.safe_load(f) model_params = config['model'] data_params = config['data'] strategy_params = config['strategy']配置文件样例:model: {learning_rate: 0.05, n_estimators: 200, num_leaves: 31},data: {window_size: 4, lead_time_days: 1, review_days: 7},strategy: {service_level: 0.95, min_order_qty: 0}。这样每次实验只改 config,不会误伤代码逻辑。同时,训练结束后把 config 和指标输出到同一个目录,便于事后追溯。
结果留痕是更重要的习惯。每跑一次实验,就在指定目录下生成结果文件,包括:验证集预测值与真实值、测试集预测值、模型参数、特征重要性、回测指标。我用一个简单函数自动保存:
def run_and_save(config_name, model, pred_df, metrics): output_dir = f'./runs/{config_name}' os.makedirs(output_dir, exist_ok=True) pred_df.to_csv(f'{output_dir}/pred.csv', index=False) model.save_model(f'{output_dir}/model.txt') with open(f'{output_dir}/metrics.json', 'w') as f: json.dump(metrics, f, ensure_ascii=False)这样你可以对不同 config 做横向对比:哪个商品类别预测误差大,哪个策略缺货率高,一眼就能看出来。我在这类赛题和项目中吃过亏:模型调优时只记录分数不记录特征,最后想回退到某个效果好的组合,只能靠记忆,非常痛苦。后来强制自己每次实验都保存全量结果,再也没出过这种问题。
另一个进阶技巧是模型集成。小数据上单模型容易波动,把 LightGBM 和岭回归的结果做加权平均,往往能显著降低方差。权重可以用验证集上的误差倒数来确定,或者简单一点,直接取平均。如果时间紧张,我一般先跑一个 LightGBM 和一个岭回归,验证集误差接近的话,就用 1:1 平均;如果 LightGBM 误差明显小,就用 0.7:0.3。集成输出后,再进入补货策略环节。
这套方案最大的收获不是排名,而是让我理解了“预测 + 决策”的闭环。预测模型再准,如果决策策略不考虑提前期和安全库存,实际收益很难提升。反过来,策略再完善,预测偏差如果太大,安全库存也只能靠高库存成本硬扛。希望这一整套从数据处理、特征工程、模型训练到策略回测的路径,能帮你少走弯路,把手上的销售数据变成真正能指导进货的采购单。
本文还有配套的精品资源,点击获取