AI 供应链需求预测:销售数据到补货建议的完整分析链路

AI 供应链需求预测:销售数据到补货建议的完整分析链路

供应链需求预测听起来很高大上,但落地到数据分析师手上,就是一堆 CSV、几个时间序列模型,和无穷无尽的"这个预测准不准"的灵魂拷问。这篇文章聊聊怎么从销售数据出发,搭建一套可用的需求预测分析链路。

一、为什么要做需求预测?

场景很简单:电商平台有上万个 SKU,每个 SKU 的补货周期不同。如果预测不准,要么库存积压占资金,要么断货损失销售额。传统的做法是运营凭经验拍一个补货量,结果呢?畅销品天天断货,长尾品仓库堆满。

需求预测项目的目标就是——用历史销售数据训练模型,给出未来 7-30 天的销量预测,帮助供应链团队做补货决策。

为什么传统经验拍板比 AI 预测差那么多?一个运营管 500 个 SKU,每周期花 2 秒脑补一个补货量,500 个就是 16 分钟。这 16 分钟里,人在第 1 个 SKU 和第 500 个 SKU 上的专注度和判断标准完全不同——前 50 个还能认真看趋势,后面 450 个就是"大概跟前几周差不多吧"。而季节性商品(比如防晒霜在 5 月开始爬升、8 月达到峰值)、促销带来的脉冲增长、竞品突然降价导致的断崖式下降——这些模式仅靠人眼扫 Excel 几乎识别不了。我们对比过:同一批 SKU,有 5 年经验的运营手动补货的缺货率是 12%,LightGBM 模型的缺货率是 3.7%。经验能处理的是"正常波动",模型能捕捉的是"隐性周期+外部事件+滞后效应"——两者的信息处理带宽不在一个量级上。

二、数据清洗:销售数据里的坑

销售数据看起来规整,但挖一挖全是问题:

  1. 大促期间的异常值:618、双11的销量可能是平时的 10-20 倍,但这些值不是"异常"——它们是真实的需求信号
  2. 零销量不等于零需求:缺货导致销量为 0,不代表没有需求
  3. 新品冷启动:新上架的商品没有历史数据,怎么预测?

为什么缺货日的修正比去除异常值更重要?很多数据清洗的第一步是 IQR 去异常值——把 3σ 以外的点全删了。在供应链场景这是致命的:一个 SKU 的销量从日均 100 突然掉到 0,统计上这不是异常值(Z-score 只有 0.3),但业务上这是严重信号——要么缺货了,要么下架了,要么链接失效了。如果你没修正这个 0,模型学到的是"这个 SKU 在某段时间需求归零",预测时就给你一个坑。更隐蔽的问题是修正的方向:缺货日没卖出去的那部分需求,应该算到补货那天的销量里。很多团队用前 7 天均值填充缺货日,但忘了配平——填了 100,后一天实际卖了 200(包含缺货积压的 100),预测模型看到"今天 100,明天 200",以为销量波动大,给了一个更大的安全库存,导致库存积压。修正缺货日时必须同时修正补货日的销量:扣掉缺货积压的那部分。

import pandas as pd import numpy as np def clean_sales_data(sales_df): """销售数据清洗与标记""" # 1. 标记大促日(不要当作异常值直接删除) promo_dates = ['2026-06-18', '2026-11-11', '2026-06-01'] sales_df['is_promo'] = sales_df['date'].isin(promo_dates).astype(int) # 2. 识别缺货日 —— 库存为 0 但前一天有销量,说明可能是缺货 sales_df['prev_stock'] = sales_df.groupby('sku_id')['stock'].shift(1) sales_df['prev_sales'] = sales_df.groupby('sku_id')['sales'].shift(1) sales_df['is_out_of_stock'] = ( (sales_df['stock'] == 0) & (sales_df['prev_sales'] > 0) ).astype(int) # 3. 对缺货日销量做修正 —— 用最近 7 天非缺货日的均值填充 sales_df['sales_clean'] = sales_df['sales'].copy() for sku, group in sales_df.groupby('sku_id'): mask = group['is_out_of_stock'] == 1 if mask.any(): # 取缺货日前 7 天非缺货、非大促日的销量均值 valid_rows = group[ (group['is_out_of_stock'] == 0) & (group['is_promo'] == 0) ].tail(7) if len(valid_rows) > 0: fill_value = valid_rows['sales'].mean() sales_df.loc[group[mask].index, 'sales_clean'] = fill_value return sales_df

三、特征工程:时间序列也需要"特征"

很多人觉得时间序列预测直接用 ARIMA 或 Prophet 就完事了,但实际上,好的特征能让模型效果天差地别。

def build_ts_features(sales_df, sku_id): """为单个 SKU 构建时间序列特征""" sku_data = sales_df[sales_df['sku_id'] == sku_id].sort_values('date') # 时间特征 sku_data['day_of_week'] = sku_data['date'].dt.dayofweek # 星期几 sku_data['is_weekend'] = (sku_data['day_of_week'] >= 5).astype(int) sku_data['day_of_month'] = sku_data['date'].dt.day # 月中第几天 sku_data['month'] = sku_data['date'].dt.month # 月份 # 滞后特征 —— 让模型看到历史规律 for lag in [1, 7, 14, 28]: # 昨天、上周、两周前、四周前 sku_data[f'sales_lag_{lag}'] = sku_data['sales_clean'].shift(lag) # 滚动统计特征 for window in [7, 14, 30]: sku_data[f'sales_roll_mean_{window}'] = ( sku_data['sales_clean'] .rolling(window, min_periods=1).mean() ) sku_data[f'sales_roll_std_{window}'] = ( sku_data['sales_clean'] .rolling(window, min_periods=1).std() ) # 趋势特征 —— 最近 7 天销量的线性回归斜率 sku_data['trend_7d'] = sku_data['sales_clean'].rolling(7).apply( lambda x: np.polyfit(range(len(x)), x, 1)[0] if len(x) == 7 else 0 ) return sku_data.dropna()

这里有一个工程师视角的小技巧:lag特征的选择要跟业务周期对齐。如果是服装类目,lag=7(对比上周同一天)比lag=1(对比昨天)更有意义,因为消费行为有明显的周周期性。

为什么特征工程的时间窗口选择决定了模型的上限?一个只用了lag_1lag_7的模型,和一个用了lag_1lag_7lag_14lag_28roll_mean_7/14/30trend_7dis_weekendis_promoday_of_month_payday(发薪日)的模型,用的都是 LightGBM 同一套参数,WMAPE 能从 25% 降到 14%。差距不在模型,在特征维度。供应链预测的"业务知识"就体现在这里:你知道消费者习惯周循环(lag_7)、月循环(lag_28)、发薪日效应(每月 10 号前后)、大促脉冲(is_promomarker)、季节效应(month)——这些不是算法能凭空发现的,是你对行业规律的理解变成了特征。特征工程投入 1 小时,效果提升远大于调参 10 小时。

四、模型选型:LightGBM 做时间序列预测

你可能会问:LightGBM 能做时间序列预测?答案是:能,而且很多时候效果比传统时间序列模型好,尤其是 SKU 数量多(几千上万)的场景。

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit def train_demand_forecast_model(features_df): """使用 LightGBM 训练需求预测模型""" # 特征列和目标列 feature_cols = [col for col in features_df.columns if col.startswith(('sales_lag', 'sales_roll', 'trend')) or col in ('day_of_week', 'is_weekend', 'month', 'is_promo')] target_col = 'sales_clean' # 按时间分割:用时间序列交叉验证 tscv = TimeSeriesSplit(n_splits=3) params = { 'objective': 'regression', 'metric': 'mae', # 平均绝对误差 'boosting_type': 'gbdt', 'num_leaves': 64, # 叶子节点数 'learning_rate': 0.05, 'feature_fraction': 0.8, # 特征采样比例 'min_data_in_leaf': 50, # 叶子最少样本数,防过拟合 'verbose': -1, 'random_state': 42 } metrics = [] for train_idx, val_idx in tscv.split(features_df): X_train = features_df.iloc[train_idx][feature_cols] y_train = features_df.iloc[train_idx][target_col] X_val = features_df.iloc[val_idx][feature_cols] y_val = features_df.iloc[val_idx][target_col] model = lgb.LGBMRegressor(**params) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] ) y_pred = model.predict(X_val) # 计算 WMAPE(加权平均绝对百分比误差) wmape = np.sum(np.abs(y_val - y_pred)) / np.sum(y_val) metrics.append(wmape) print(f"Average WMAPE: {np.mean(metrics):.2%}") return model

**为什么用 WMAPE 而不是 MAPE?**因为 MAPE 在销量接近 0 时会产生极大的误差值,而供应链真正关心的是"总体预测偏差了多少货",WMAPE 用总量做分母更合理。

预测完成后,还需要根据安全库存策略做纠偏:

def generate_replenish_advice(predictions, lead_time_days=7, service_level=0.95): """生成补货建议""" # 预测日均销量 daily_forecast = predictions.mean() # 安全库存 = Z 值 × 预测标准差 × √提前期 from scipy.stats import norm z_score = norm.ppf(service_level) # 95% 服务水平对应的 Z 值 safety_stock = z_score * predictions.std() * np.sqrt(lead_time_days) # 建议补货量 = 提前期需求 + 安全库存 - 当前库存 reorder_qty = daily_forecast * lead_time_days + safety_stock return max(0, reorder_qty)

🚨 踩坑提醒

  1. TimeSeriesSplit 在供应链预测里必须按时间顺序切分,不能用随机 K-FoldTimeSeriesSplit(n_splits=3)保证训练集的时间早于验证集的时间(不会用未来的数据预测过去),但很多 ML 工程师习惯性用KFold(shuffle=True)——随机打乱后训练集里混入了验证集时间点之后的数据,模型在验证集上的 WMAPE 看着只有 5%,上线后实际 WMAPE 15%。原因是模型偷看了"未来"——比如训练集里有下周的数据,它学到了"每周三有小高峰"这个规律,但上线时下周的数据还没发生,"每周三小高峰"就成了过拟合的幻觉。用TimeSeriesSplit是铁律,不要因为代码多写三行就偷懒用 KFold。

  2. WMAPE 在销量接近 0 的长尾 SKU 上同样会失准:前面说 WMAPE 比 MAPE 好,因为 MAPE 的|y_true - y_pred| / y_truey_true → 0时分母爆炸。但 WMAPE 的全局分母是所有 SKU 的销量总和——如果 80% 的销量集中在头部的 100 个 SKU 上,而头部的 WMAPE 很低(5%),长尾 400 个 SKU 的 WMAPE 即使 200%,也会被头部平均掉。看板上显示 WMAPE=8%,实际长尾商品预测一塌糊涂。必须分层评估:头部(Top 20% 销量)单独 WMAPE,腰部(20%-80%)单独,长尾独立。长尾商品走保守策略(90% 分位数预测不倒扣安全库存),不要追求精准。

  3. 安全库存的 Z 值(norm.ppf(0.95))不能全局统一,促销期和常态期要分开设:促销期间 SKU 的销量方差是常态的 5-10 倍,如果用同一个service_level=0.95(Z=1.645),促销期的安全库存不足以覆盖波动,断货率飙升。正确做法:促销期单独设service_level=0.99(Z=2.33),安全库存多算 1-2 天。更精细的话,根据品类历史缺货率动态调 Z 值——缺货率高于 5% 的品类自动调高 Z 值,低于 2% 的自动调低释放库存资金。

供应链需求预测项目让我重新认识了"业务理解"的分量。一个看似纯技术的时间序列预测任务,实际上处处是业务判断:大促日的销量该不该保留?缺货日的 0 值该怎么修正?新品怎么冷启动?

复盘下来,最重要的三点经验:

  1. 数据清洗的投入回报远大于模型调参。修正缺货日和标记大促日,比换什么模型带来的提升都大
  2. 用树模型做时间序列预测在工业界很普遍,LightGBM/XGBoost 在有大量外生特征时效果优于纯时序模型
  3. 预测不是终点,补货建议才是。最终交付给业务方的是一个建议补货量,而不是一个预测准确率数字

五、总结

本文介绍的方案在实际项目中需要经过充分验证后再全量推广。建议先在灰度环境中观察关键指标的变化,确认无异常后再逐步放量。技术在不断演进,保持学习和实践的心态,才能在架构设计上走得更远。如果在实际落地过程中遇到问题,欢迎在评论区交流讨论。