做供应链优化项目的这三年,我有一个越来越强烈的感受:算法模型的差异,远没有数据预处理的差异来得大。同一个预测模型,喂给它的数据干不干净、特征构造得合不合理,在线上跑出来的效果可能差出一倍还多。尤其是供应链场景,数据来自ERP、WMS、TMS、门店POS、电商平台后台……每一路数据都有各自的脾气,缺失、错位、重复、口径不统一,全是日常操作。
这篇文章不聊模型选型,也不讲调参黑科技,就聚焦在供应链优化里最常用、也最容易被轻视的6个数据预处理技巧。它们不是教科书里的泛泛概念,而是我在真实项目里反复用过、验证过、也踩过坑之后沉淀下来的实操方案,适合正在做需求预测、库存优化、补货计划或者供应链数据分析的读者参考。会用Python和pandas做演示,但核心思路放在任何工具链里都通用。
1. 供应链数据预处理的整体思路:先解决“能不能用”,再谈“好不好用”
很多数据工程师或者算法工程师接手供应链项目时,第一步就是急着看模型指标。但我个人经验是,供应链数据预处理的第一优先级,永远是“让数据能真实表达业务发生的过程”,而不是直接追求特征丰富度。为什么这么说?因为供应链数据有四个非常典型的毛病,每一步都可能让下游模型学到错误规律。
1.1 供应链数据的四个典型“毛病”
第一个毛病是多源异构。订单数据在订单系统里,库存数据在WMS里,补货计划可能又散落在Excel里。每个系统的字段命名、时间粒度、数值单位都不一样。最典型的就是“数量”字段,有的系统存的是件数,有的系统存的是箱数,有的系统甚至把负数当退货,不做对齐的话,后续所有统计都会失真。
第二个毛病是缺失模式复杂。供应链数据里的缺失,很多时候不是“没记录”,而是“业务上就没有发生”。比如某个SKU在某天没有销量,可能是因为缺货,可能是门店没营业,也可能是压根没有铺货。这三种情况对预处理的含义完全不同,但原始数据里往往只表现为同一个值:0或者空。
第三个毛病是时间口径混乱。订单时间、发货时间、签收时间、库存快照时间,这些时间戳分布在不同的时区、不同的系统里,有的按自然日归档,有的按工作日归档。如果不做对齐,模型看到的“时间线”就是错位的,预测出来的需求峰值可能整体偏移一两天。
第四个毛病是重复与幂等缺失。重复订单、重复同步的库存快照、跨系统接口重试导致的数据重复写入,都是供应链数据里的常态。如果不在预处理阶段做去重和幂等处理,模型训练集里同一笔业务被算成两次,轻则指标虚高,重则补货建议直接翻倍。
这四个毛病叠加在一起,会让一个看起来“很简单”的需求预测项目,变得异常棘手。所以我的建议是:预处理阶段宁可多花一两天做数据体检,也不要把问题带进模型里。
1.2 为什么预处理比模型调参更影响效果
很多人会高估模型调参的作用,低估数据质量的作用。拿需求预测来说,用一个普通的XGBoost加上干净、特征合理的训练数据,效果通常好过一个花了大量精力调参但输入数据混乱的复杂模型。
原因在于,供应链的业务规律往往是强信号、弱噪声叠加出来的。比如节假日前的需求抬升、促销带来的脉冲、季节性波动,这些规律在数据里是存在的,但很容易被脏数据干扰。缺货导致的销量骤降如果被当成“需求下降”学进去,模型就会错误地降低未来的补货预测;重复订单如果被反复计入训练集,模型对需求量的估计就会系统性偏高。
所以预处理的意义,不只是“让数据不报错”,而是让数据尽量还原真实业务含义。在动手做特征工程之前,先把数据清洗、对齐、去重、防泄漏这部分做好,模型的提升幅度往往是立竿见影的。
2. 技巧一:缺失值填充前,先分清“没发生”和“没录入”
缺失值填充是最基础的预处理操作,但供应链场景里很多人一上来就fillna(0)或者fillna(mean),这是非常危险的。因为供应链数据里的缺失值,往往带有明确的业务语义,盲目填充等于把业务信息直接抹掉了。
2.1 不要把“缺货”和“没录入”当成一回事
举一个真实的例子。门店A的SKU X在3月10号销量为0,门店B的同款SKU在同一天销量也为0。表面上看,这两个0一模一样,但背后的业务原因可能完全不同:门店A是因为库存显示有货但没人买,门店B是因为货已经卖完缺货了。如果直接把两个0都丢进模型,模型学到的就是“某天某店这个SKU没人买”,而实际上门店B的情况是“想买但买不到”,这部分需求被压制了。
这种需求被称为“被抑制需求”,在供应链优化里非常关键。缺货导致的销量低迷如果被当成真实需求,补货模型会进一步降低该SKU的备货量,形成“越缺货越不补货”的恶性循环。
所以第一步,不要急着填充,先去看缺失值或者0值背后有没有可解释的业务事件。我通常的做法是把以下几个字段关联起来排查:
- 该SKU当天的库存数量是否为0或者低于安全库存;
- 该SKU当天是否有在途补货;
- 该SKU当天是否有促销活动(促销期间销量通常不该为0);
- 该门店当天是否正常营业(排除未营业导致的0)。
如果关联之后发现“库存为0且门店正常营业”,那这个销量缺失/为0大概率是缺货造成的,需要在预处理时打上“stockout”标志位,甚至单独建模修正。
2.2 业务日历感知的填充方案
在明确缺失原因之前,我建议至少做到“按业务维度分组填充”,而不是全局填充。核心思路是:对于同一个SKU、同一个门店/仓库、同一个星期几,用历史可用值来推断缺失值。
给你一段可以直接改用的参考代码:
import pandas as pd import numpy as np def fill_missing_by_business_calendar(df): # 假设df包含:sku_id, store_id, date, sales, inventory_qty df = df.copy() df['date'] = pd.to_datetime(df['date']) df['weekday'] = df['date'].dt.weekday df['is_weekend'] = df['weekday'].apply(lambda x: 1 if x >= 5 else 0) # 方法1:按 SKU + 门店 + 星期几 取历史滚动中位数填充 df['sales_clean'] = df['sales'] mask = df['sales'].isna() | (df['sales'] < 0) # 先用同 SKU + 同门店 + 同星期的中位数填充 grp = df.groupby(['sku_id', 'store_id', 'weekday'])['sales'] median_by_group = grp.transform('median') df.loc[mask, 'sales_clean'] = median_by_group[mask] # 如果分组后仍然为空,再用同 SKU + 同星期的全局中位数 mask2 = df['sales_clean'].isna() grp2 = df.groupby(['sku_id', 'weekday'])['sales'] median_by_sku_weekday = grp2.transform('median') df.loc[mask2, 'sales_clean'] = median_by_sku_weekday[mask2] # 最后剩余的空值,向前填充最近一个有效值 df['sales_clean'] = df['sales_clean'].fillna(method='ffill') return df这段代码的逻辑很简单:先按最小业务粒度(SKU+门店+星期几)找历史中位数,找不到就放宽到SKU+星期几,还不行就用时间上的前向填充。这样处理的好处是,填充值本身携带了“这个SKU在这个星期几大概卖多少”的规律,比全局均值靠谱得多。
提示:不要对缺货导致的销量0直接用这个填充。缺货mask需要单独处理,否则填充出来的值会把真实的缺货信号抹掉。更合理的做法是:先标记缺货日期,再用邻近非缺货日期的值估算“如果不断货本应卖多少”。
3. 技巧二:SKU多码归一化,别让同一个商品在模型里变成两个特征
供应链项目里,SKU的身份问题是我见过最多人踩坑的地方,也是最容易在早期被忽略的问题。不同系统里,同一个商品可能有好几套编码:ERP里用的是内部物料编码,电商平台用的是平台SKU ID,仓库WMS里可能又有自己的货号。如果不做归一化,模型会以为这是两个不同的商品,特征分裂、预测混乱、库存计划错位,后面所有环节都会跟着出问题。
3.1 多套编码并行时的对齐方案
处理这个问题的前提,是先摸清编码映射关系。最常见的做法是建立一张SKU主数据映射表,把各个系统的编码统一映射到一个内部标准SKU ID上。
举个例子,假设你有两张表:
- 订单表
order_df:包含platform_sku(平台编码)、sales_qty; - 库存表
inv_df:包含wms_code(仓库编码)、stock_qty; - 商品主数据表
sku_mapping:包含platform_sku、wms_code、internal_sku、category、unit_weight等。
在做任何聚合之前,先用映射表把订单表和库存表的编码统一到internal_sku:
order_df = order_df.merge( sku_mapping[['platform_sku', 'internal_sku']], on='platform_sku', how='left' ) inv_df = inv_df.merge( sku_mapping[['wms_code', 'internal_sku']], on='wms_code', how='left' )合并完之后一定要检查映射覆盖率。我见过太多项目因为主数据不完整,merge之后丢了几万行数据,还浑然不知。检查代码很简单:
print("订单表映射缺失率:", order_df['internal_sku'].isna().mean()) print("库存表映射缺失率:", inv_df['internal_sku'].isna().mean())如果缺失率超过0.5%,先别急着继续,回去补全映射表。缺失率高的时候,任何下游统计都是不可信的。
3.2 构造SKU层级聚合特征
SKU归一化之后,还有一个很实用的技巧:在SKU维度之上构造层级聚合特征。因为有时候SKU粒度太细,数据稀疏,单看一个SKU的历史销量,规律很不稳定;但如果把同一品类、同一品牌、同一仓库的商品聚在一起看,趋势就明显得多。
具体操作上,我会在SKU归一化的基础上,增加品类级、品牌级、仓库级的聚合特征,例如:
- 该SKU所属品类当天的总销量;
- 该SKU所属品牌过去7天的平均日销量;
- 该SKU所在仓库当天的总出库量。
这些特征在需求预测里非常有帮助,尤其是新品或者长尾SKU,自身历史太短、数据太少,靠上层品类的规律来做冷启动,准确率能提升不少。
注意:层级聚合特征必须和业务层级对齐。如果模型预测的是“门店-商品”粒度的需求,就不要混入大区级甚至全国级的销量特征,否则会引入聚合层级带来的偏差。我之前试过把全国总销量作为特征喂给门店级模型,结果模型严重偏向大盘趋势,忽略了门店自身的差异化规律,预测效果反而变差。
4. 技巧三:时间序列对齐与业务日历重采样,需求峰谷才不会错位
供应链数据的时间口径问题,是最容易被忽略、但后果又最严重的问题之一。尤其是当你需要把多个系统的数据合并成一张建模宽表时,时间对齐做不好,后患无穷。
4.1 时间戳乱象:时区、归档时间、业务时间
常见的坑有三个:
第一个坑是时区不一致。多门店跨区域运营时,有的系统用的是UTC时间,有的用本地时间。如果直接拿原始时间戳做日粒度统计,同一个订单可能被划到不同的自然日,导致日销量出现虚假的波动。
第二个坑是归档时间 vs 业务时间。很多系统里记录的“下单时间”其实不是业务实际发生的时间,而是数据写入或归档的时间。对于补货类项目,我们关心的是“需求什么时候发生”;对于财务对账类项目,可能更关心“订单什么时候确认”。同一张订单表,不同业务口径要用的时间字段是完全不同的。
第三个坑是自然日 vs 业务日。电商和零售行业里,业务日的边界往往不是零点。比如某些业态把凌晨2点到次日凌晨2点算一个营业日,如果按自然日聚合,每天开头和结尾的销售就会被切碎,波动变大,模型更难学到规律。
4.2 按业务周期重采样的实操示例
处理这个问题,我通常先做两件事:统一时区,再统一业务日边界。统一时区很简单,把所有时间字段都转成同一个时区;统一业务日边界,则需要根据业务规则设置偏移量。
# 统一时区 df['order_time'] = pd.to_datetime(df['order_time'], utc=True) df['order_time'] = df['order_time'].dt.tz_convert('Asia/Shanghai') # 假设业务日是凌晨2点为界:将凌晨0-2点的订单归到前一个业务日 df['business_date'] = df['order_time'] - pd.Timedelta(hours=2) df['business_date'] = df['business_date'].dt.date按业务日聚合之后,再做重采样,把不同频率的数据统一到同一时间粒度。比如库存表是每天一个快照,订单表是每笔订单一行,销售目标表可能是每周一行,通常我会把它们统一到日频,然后再按需要降采样到周频或月频。
# 日频销量 daily_sales = ( df.groupby(['internal_sku', 'store_id', 'business_date'])['sales_qty'] .sum() .reset_index() ) # 转为周频,方便和每周补货计划对齐 weekly_sales = ( daily_sales.assign( week_start=pd.to_datetime(daily_sales['business_date']) - pd.to_timedelta( pd.to_datetime(daily_sales['business_date']).dt.weekday, unit='D' ) ) .groupby(['internal_sku', 'store_id', 'week_start'])['sales_qty'] .sum() .reset_index() )这一步做完了,你才能放心地把销售数据和库存数据、补货数据join到同一张表里。
提示:在项目启动早期,最好把一份数据按不同时间口径分别统计一遍,对比日粒度、业务日粒度、自然日粒度的差异。如果差异超过5%,说明时间口径对结果影响很大,必须和业务方确认统一口径。如果差异不大,那至少心里有数,后期对结论的抗性也能更强。
5. 技巧四:重复订单和重复快照,去重不只是drop_duplicates
重复数据在供应链里出现的频率,远超很多人的想象。我在项目里碰到最多的是两类:重复订单和重复库存快照。
重复订单的典型来源是:前端用户重复提交、接口重试、多系统间数据同步导致同一笔订单被记录多次。重复库存快照则常见于定时任务重复执行或数据管道重跑,比如本来每小时抓一次库存,某天管道故障导致同一小时的快照被写入了两条。
5.1 用“业务主键 + 事件时间”来判定重复
去重的第一步,是定义“什么样的记录算重复”,这需要找一个稳定且唯一的业务主键。订单场景下,通常是订单号+商品行号;库存快照场景下,通常是SKU+仓库+快照时间。
直接drop_duplicates()在字段少的时候可以用,但要小心:如果订单号这一列本身有脏数据,比如前后空格、大小写差异、不可见字符,简单的drop_duplicates根本识别不出重复。
我常用的做法是先对主键列做标准化清洗,再去重:
def normalize_key(series): return ( series.astype(str) .str.strip() .str.lower() .str.replace(r'\s+', '', regex=True) ) df['order_no_clean'] = normalize_key(df['order_no']) df['line_no_clean'] = normalize_key(df['line_no']) df = df.drop_duplicates(subset=['order_no_clean', 'line_no_clean_combine'], keep='last')注意这里keep参数的选择。对于订单数据,我倾向于保留最后一条,因为后写入的往往是状态更新后的最新记录;对于库存快照,如果同一小时出现多条,可能需要根据抓取时间戳取最新一条。
5.2 幂等键:从源头防止重复
去重是事后补救,更好的方案是在数据管道层面做幂等设计。所谓幂等,就是同一份数据无论被写入几次,最终效果都一样。
在数仓或者数据湖里,常见的做法是给每张表加一个唯一键,用INSERT OVERWRITE或者MERGE的方式写入。比如日粒度库存快照表,唯一键是(sku_id, warehouse_id, snapshot_date),重复跑任务时,后一次写入会覆盖前一次,天然不会产生重复记录。
如果用的是增量同步管道,建议在写入目标表时做一次基于唯一键的去重,防止上游接口重试导致重复插入。
注意:去重之前一定要先确认“重复”是否真的是重复。有朋友遇到过把不同渠道的同名订单当成重复数据删掉的情况。比如同一客户在微信小程序和天猫都下了一单,两个订单号恰好相同(因为两个系统各自编号),但业务上这是两笔真实订单。所以去重前,最好把业务主键定义写清楚,并和业务方确认。
6. 技巧五:滞后特征与滚动窗口构造,需求预测的“燃料”就在这里
需求和库存预测里,真正让模型跑起来的核心特征,往往是时间序列特征:历史销量、历史库存、历史促销强度等。而这类特征最常见的构造方式,就是滞后特征和滚动窗口统计。
6.1 未来信息陷阱:为什么不能直接用当前库存“预测”未来销量
做特征工程时,最隐蔽也最容易犯的错误,是无意中把未来信息引入训练集。举个典型例子:如果想用第t天的数据预测第t+1天的销量,那么第t天的库存、第t天的销量、第t天是否促销这些信息都可以用作特征,因为它们在当时是已知的。
但如果一不小心把第t+1天的实际销量也放进了特征,模型在训练阶段会“偷看答案”,指标好看得惊人,上线之后立刻现原形。这类问题在特征工程里被称为“数据泄漏”。
另一个常见的泄漏场景是:用全量数据计算某些统计特征(比如用包含未来数据的时间段来计算一个特征的标准差或均值),再喂给模型。时间序列里,所有特征都必须是基于截止当前时刻的信息计算的。
6.2 用shift和rolling构造不回看未来的特征
pandas里的shift和rolling是构造滞后特征和滚动窗口特征的两个基础工具。关键是使用时要注意shift的顺序:预测未来,意味着特征要相对目标值做滞后。
# 假设数据已经按 sku + store + business_date 排序 df = df.sort_values(['internal_sku', 'store_id', 'business_date']) g = df.groupby(['internal_sku', 'store_id'])['sales_qty'] # 滞后特征:前1天、前2天、前7天的销量 df['lag_1'] = g.shift(1) df['lag_2'] = g.shift(2) df['lag_7'] = g.shift(7) # 滚动窗口特征:过去7天、14天的滚动均值 df['roll_mean_7'] = g.transform(lambda x: x.shift(1).rolling(7, min_periods=1).mean()) df['roll_mean_14'] = g.transform(lambda x: x.shift(1).rolling(14, min_periods=1).mean()) # 滚动最大值、最小值、标准差也很有用 df['roll_max_7'] = g.transform(lambda x: x.shift(1).rolling(7, min_periods=1).max()) df['roll_std_7'] = g.transform(lambda x: x.shift(1).rolling(7, min_periods=1).std())可能有人会问:为什么滚动平均值里要先shift(1)再取rolling?因为直接用当天的值参与滚动,模型在预测第t天的销量时会把第t天的销量也当成特征,这不合理。必须先往后挪一步,让窗口里只包含历史数据。
技巧:构造滚动窗口特征时,窗口大小不是越大越好。过大的窗口会把很久远、已经不相关的模式带进来,给模型增加噪声。我在零售预测项目里常用的窗口是7、14、28天,分别对应周规律、双周规律和月规律。你可以根据业务周期试几组不同的窗口,做一轮特征重要性排序,让数据告诉你哪些窗口更有价值。
7. 技巧六:时序场景的样本切分,防泄漏比调参重要十倍
最后一个技巧,其实是整个特征工程的保障环节:样本切分。很多人做机器学习时习惯用train_test_split(random_state=42),或者直接上交叉验证,但在供应链时序数据里,这种随机切分是灾难性的。
7.1 普通K折在时序场景有多坑
随机的K折会把时间打乱,让模型用“未来的数据”去训练“过去的数据”。比如训练集里包含了第100天的样本,验证集里却出现了第10天的样本,模型在训练时已经见过第10天之后的所有信息,验证集的指标自然虚高。
更麻烦的是,供应链数据里同一个SKU、同一个门店的样本往往跨了很长的时间段,随机切分会导致同一个时间序列的相邻点被分到训练集和验证集里,信息泄漏几乎是必然的。
7.2 推荐的做法:扩窗切分和滚动切分
时序场景我一般用两种切分方式:
第一种是扩窗切分(Expanding Window)。先用前12个月做训练,验证第13个月;再用前13个月训练,验证第14个月;以此类推。这样模型的训练数据越来越多,更贴近真实业务中模型逐步积累历史数据的过程。
第二种是滚动切分(Sliding Window)。固定训练窗口大小(比如只用最近12个月),逐步向后滚动。这种做法的好处是模型不会无限依赖太老的样本,适合业务规律本身会漂移的场景,比如快消品行业的季节性变化。
核心代码逻辑大概长这样:
def time_series_split(df, date_col, train_days, test_days, min_train_date): # 按时间顺序获取日期列表 dates = df[date_col].sort_values().unique() start_idx = 0 while start_idx + train_days + test_days < len(dates): train_start = dates[start_idx] train_end = dates[start_idx + train_days - 1] test_start = dates[start_idx + train_days] test_end = dates[start_idx + train_days + test_days - 1] train_mask = (df[date_col] >= train_start) & (df[date_col] <= train_end) test_mask = (df[date_col] >= test_start) & (df[date_col] <= test_end) yield df[train_mask], df[test_mask] start_idx += test_days # 每次向后滚动一个test_days的步长用这个函数可以在不泄漏的情况下,实现多轮训练和验证。在验证每个切分组合时,建议同时统计多个SKU上的平均误差,而不是只看一两个头部SKU,防止模型只在数据充足的爆款上表现好。
注意:如果特征里有门店、仓层级的分组,切分之前先不要shuffle,保持时间顺序。同时保证所有和“时间”相关的聚合操作都发生在切分之后、特征构造阶段,不要用全量数据算好窗口特征再切分,那也是一种泄漏。
8. 实录:供应链数据预处理的常见坑与排查顺序
前面6个技巧讲的是做法,这里集中整理一下我在项目里真实遇到过的坑,以及排查问题的顺序。有些问题不大,但排查起来特别费时间,提前知道能省很多事。
8.1 问题速查表
| 问题现象 | 常见原因 | 排查思路 |
|---|---|---|
| 模型训练指标很好,线上预测一塌糊涂 | 训练时引入了未来信息 | 检查特征是否用了shift;检查切分是否按时间 |
| 同一个SKU在不同报表里销量对不上 | 多套编码或不同时间口径 | 先核对SKU映射表、业务日边界 |
| 预测值总是被低估 | 缺货导致的销量0被当成真实需求 | 增加缺货标记,单独修正被抑制需求 |
| 某个门店/仓库的预测特别差 | 该站点数据稀疏或重复 | 尝试层级聚合特征做冷启动 |
| 滚动窗口特征排序时,重要性高得异常 | 滚动均值里包含当天值 | 检查rolling前是否shift(1) |
| 跑批任务重复执行后数据翻倍 | 缺少幂等键 | 目标表加唯一键,用MERGE/OVERWRITE写入 |
8.2 几个亲测有效的调试顺序
如果你的供应链数据项目效果不如预期,我建议按下面这个顺序排查,而不是一上来就调模型参数:
第一步,先做数据体检。对每个字段统计缺失率、重复率、时间范围、取值的分布。这一步能排除掉大部分低级问题。
第二步,检查口径一致性。把销量、库存、补货目标这几张表的粒度、单位、时间边界全部对齐,确认没有“同义不同值”的字段混入。
第三步,检查特征是否干净。重点看那些和业务直觉矛盾的点,比如促销期间的销量反而低于平时,出现这种异常时不要急着怀疑模型,先去查是不是去重或者填充出了问题。
第四步,再做分时段验证。用扩窗切分多跑几轮,看看模型在不同时间段的稳定性。如果模型只在某几个月效果好、其他时间差很多,大概率是特征构造里有季节相关的手工特征没做对,或者是滚动窗口长度不合适。
第五步,最后才考虑调参和模型升级。这个时候你的数据预处理基本成型了,调参才有意义,否则就是在垃圾数据上做精装修。
写在最后
回头看看这6个技巧,其实都有一个共同的底层逻辑:让数据尊重业务真实语义,让特征尊重时间顺序。缺失值填充前先问“为什么缺”,SKU归一化前先看“哪些编码是同一个商品”,时间对齐前先确认“业务日到底从哪里开始”,构造特征前先确认“此时模型能看到什么”——这些思考方式,比任何具体的库函数都重要。
我个人在实际项目中有个习惯:每个预处理步骤都留下一段可以追溯的代码,并且把每一步的输入、输出、过滤规则记录在一个简单的日志里。这样做的好处是,当业务方跑过来问“为什么这个数据和处理前不一样”的时候,你能在两分钟内说清楚来龙去脉,而不是靠回忆。
另外,这一步永远不要省:在所有预处理完成之后,抽几个样本,人工核对一下“原始数据 → 处理前 → 处理后”的对应关系。别看这一步笨,它曾经帮我抓住过一个因为时区转换写错导致整体偏移一天的问题,那一次差点把整版预测结果都带偏。
数据预处理不性感,做出来也没有漂亮的模型结构图可以晒,但它是整个供应链优化项目的底盘。底盘稳了,模型才有资格去谈准确率;底盘不稳,再高级的算法也扛不住。