供应链优化必看:6个数据预处理实战技巧(含Python示例)

供应链优化必看:6个数据预处理实战技巧(含Python示例) 做供应链优化项目的这三年我有一个越来越强烈的感受算法模型的差异远没有数据预处理的差异来得大。同一个预测模型喂给它的数据干不干净、特征构造得合不合理在线上跑出来的效果可能差出一倍还多。尤其是供应链场景数据来自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(methodffill) 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_skuorder_df order_df.merge( sku_mapping[[platform_sku, internal_sku]], onplatform_sku, howleft ) inv_df inv_df.merge( sku_mapping[[wms_code, internal_sku]], onwms_code, howleft )合并完之后一定要检查映射覆盖率。我见过太多项目因为主数据不完整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], utcTrue) df[order_time] df[order_time].dt.tz_convert(Asia/Shanghai) # 假设业务日是凌晨2点为界将凌晨0-2点的订单归到前一个业务日 df[business_date] df[order_time] - pd.Timedelta(hours2) 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_startpd.to_datetime(daily_sales[business_date]) - pd.to_timedelta( pd.to_datetime(daily_sales[business_date]).dt.weekday, unitD ) ) .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, , regexTrue) ) 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], keeplast)注意这里keep参数的选择。对于订单数据我倾向于保留最后一条因为后写入的往往是状态更新后的最新记录对于库存快照如果同一小时出现多条可能需要根据抓取时间戳取最新一条。5.2 幂等键从源头防止重复去重是事后补救更好的方案是在数据管道层面做幂等设计。所谓幂等就是同一份数据无论被写入几次最终效果都一样。在数仓或者数据湖里常见的做法是给每张表加一个唯一键用INSERT OVERWRITE或者MERGE的方式写入。比如日粒度库存快照表唯一键是(sku_id, warehouse_id, snapshot_date)重复跑任务时后一次写入会覆盖前一次天然不会产生重复记录。如果用的是增量同步管道建议在写入目标表时做一次基于唯一键的去重防止上游接口重试导致重复插入。注意去重之前一定要先确认“重复”是否真的是重复。有朋友遇到过把不同渠道的同名订单当成重复数据删掉的情况。比如同一客户在微信小程序和天猫都下了一单两个订单号恰好相同因为两个系统各自编号但业务上这是两笔真实订单。所以去重前最好把业务主键定义写清楚并和业务方确认。6. 技巧五滞后特征与滚动窗口构造需求预测的“燃料”就在这里需求和库存预测里真正让模型跑起来的核心特征往往是时间序列特征历史销量、历史库存、历史促销强度等。而这类特征最常见的构造方式就是滞后特征和滚动窗口统计。6.1 未来信息陷阱为什么不能直接用当前库存“预测”未来销量做特征工程时最隐蔽也最容易犯的错误是无意中把未来信息引入训练集。举个典型例子如果想用第t天的数据预测第t1天的销量那么第t天的库存、第t天的销量、第t天是否促销这些信息都可以用作特征因为它们在当时是已知的。但如果一不小心把第t1天的实际销量也放进了特征模型在训练阶段会“偷看答案”指标好看得惊人上线之后立刻现原形。这类问题在特征工程里被称为“数据泄漏”。另一个常见的泄漏场景是用全量数据计算某些统计特征比如用包含未来数据的时间段来计算一个特征的标准差或均值再喂给模型。时间序列里所有特征都必须是基于截止当前时刻的信息计算的。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_periods1).mean()) df[roll_mean_14] g.transform(lambda x: x.shift(1).rolling(14, min_periods1).mean()) # 滚动最大值、最小值、标准差也很有用 df[roll_max_7] g.transform(lambda x: x.shift(1).rolling(7, min_periods1).max()) df[roll_std_7] g.transform(lambda x: x.shift(1).rolling(7, min_periods1).std())可能有人会问为什么滚动平均值里要先shift(1)再取rolling因为直接用当天的值参与滚动模型在预测第t天的销量时会把第t天的销量也当成特征这不合理。必须先往后挪一步让窗口里只包含历史数据。技巧构造滚动窗口特征时窗口大小不是越大越好。过大的窗口会把很久远、已经不相关的模式带进来给模型增加噪声。我在零售预测项目里常用的窗口是7、14、28天分别对应周规律、双周规律和月规律。你可以根据业务周期试几组不同的窗口做一轮特征重要性排序让数据告诉你哪些窗口更有价值。7. 技巧六时序场景的样本切分防泄漏比调参重要十倍最后一个技巧其实是整个特征工程的保障环节样本切分。很多人做机器学习时习惯用train_test_split(random_state42)或者直接上交叉验证但在供应链时序数据里这种随机切分是灾难性的。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归一化前先看“哪些编码是同一个商品”时间对齐前先确认“业务日到底从哪里开始”构造特征前先确认“此时模型能看到什么”——这些思考方式比任何具体的库函数都重要。我个人在实际项目中有个习惯每个预处理步骤都留下一段可以追溯的代码并且把每一步的输入、输出、过滤规则记录在一个简单的日志里。这样做的好处是当业务方跑过来问“为什么这个数据和处理前不一样”的时候你能在两分钟内说清楚来龙去脉而不是靠回忆。另外这一步永远不要省在所有预处理完成之后抽几个样本人工核对一下“原始数据 → 处理前 → 处理后”的对应关系。别看这一步笨它曾经帮我抓住过一个因为时区转换写错导致整体偏移一天的问题那一次差点把整版预测结果都带偏。数据预处理不性感做出来也没有漂亮的模型结构图可以晒但它是整个供应链优化项目的底盘。底盘稳了模型才有资格去谈准确率底盘不稳再高级的算法也扛不住。