基于DNN的长尾商品销量预测:从数据预处理到模型部署
简介面向电商供应链与算法研发人员提供一套基于TensorFlow 1.13实现的长尾商品销量DNN预测项目源码覆盖7天、30天与60天销量预测目标是辅助备货决策。由于长尾商品销量稀疏、波动明显传统统计方法难以建模该项目给出了DNN的完整实现方案。压缩包共6个文件包含5个Python脚本与1个Markdown说明文档体量仅20KB代码结构精简。脚本按单输出与多输出两类场景划分分别提供训练与预测入口并完整演示了tf.summary.FileWriter记录TensorBoard日志、tf.train.Saver保存模型、训练/验证/测试集分离、自定义早停连续10轮精度不升即停以及通过import_meta_graph和get_operation_by_name离线加载模型等核心操作。此外还展示了超参数选择与泛化评估的标准流程方便直接迁移到自己的数据上。目前已有127人学习适合正在从事长尾商品预测、希望从零搭建DNN训练管线的开发者也可作为供应链备货算法的参考实现。1. 长尾商品销量预测为什么 DNN 比“按经验备货”更值得做做电商供应链的人基本都躲不过“长尾商品”这三个字。款式多、单款销量少、生命周期短备货多了压在仓库吃资金备货少了直接断货丢订单。传统做法无非是按历史均值加个安全库存或者靠采购员的个人经验拍脑袋结果要么是爆款断货被平台扣分要么是几百个滞销 SKU 成了“不动销库存”最后打折清仓都卖不掉。这个标题里的“python实现的长尾商品销量DNN预测”本质上是把一堆长尾商品的离散、稀疏、高波动的销量记录喂给一个多层全连接网络让它学习出“哪些特征组合会导致销量上升或下降”的非线性关系然后输出未来一段时间每个 SKU 的预测销量。我做过的几版方案里DNN 在长尾场景下的效果确实比线性回归和树模型稳定原因后面会详细拆。但前提是数据清洗和特征工程做扎实否则再深的网络也会被长尾分布里的极端值带偏。这篇文章适合正在做电商选品、供应链补货、库存周转优化的算法工程师或数据分析师也适合刚入门想找一个真实工业场景练手的 Python 开发者。我会从数据预处理、特征构造、模型结构、训练调参、落地部署几个环节把完整的可复现步骤和踩过的坑讲清楚照着做能少走不少弯路。2. 长尾销量数据的预处理分位数剪枝、log1p 变换与样本权重2.1 为什么长尾销量数据不能直接喂给 DNN长尾商品的销量分布极度偏斜。拿一个常见的服装类目举例上千个 SKU 里头部爆款可能一天卖出几百件但腰部以下大部分商品周销量在 0 到 5 件之间还有不少 SKU 连续多周销量为 0。这种数据直接丢给 DNN损失函数会被少数大销量样本主导模型会倾向于把一切都预测成“中等偏上”的销量小销量商品的误差反而被忽略。另外DNN 对输入特征的尺度非常敏感。销量数值本身可能从 0 到 300 分布价格从 9.9 到 599 波动评价数从 0 到几万直接用原始数值喂进去梯度更新会被大数值特征霸占。所以核心思路是“先压缩动态范围再让模型去拟合”。常见做法是两步第一步做分位数剪枝把极端大值和明显异常的数据先去掉或截断第二步做 log1p 变换把 0 到几百的偏斜分布拉到接近正态。2.2 预处理实操从原始订单表到干净的训练集假设原始数据是订单明细表字段包括order_id、sku_id、order_date、quantity、price、category_id。第一步先把订单聚合到“SKU × 天”粒度因为销量预测的最小时间单位通常是天再按周聚合也行但日粒度能保留更多波动信息。import pandas as pd import numpy as np # order_df: 原始订单明细 order_df[order_date] pd.to_datetime(order_df[order_date]) # 聚合到 sku x 天 daily_sales order_df.groupby([sku_id, order_date])[quantity].sum().reset_index() # 生成连续日期序列缺失日期补 0该日无销量 all_dates pd.date_range(daily_sales[order_date].min(), daily_sales[order_date].max(), freqD) full_index pd.MultiIndex.from_product([daily_sales[sku_id].unique(), all_dates], names[sku_id, order_date]) daily_sales daily_sales.set_index([sku_id, order_date]).reindex(full_index, fill_value0).reset_index() # 分位数剪枝把销量高于 99.5 分位的天截断到该分位值 upper_limit daily_sales[quantity].quantile(0.995) daily_sales[quantity_clipped] daily_sales[quantity].clip(upperupper_limit) # log1p 变换 daily_sales[quantity_log] np.log1p(daily_sales[quantity_clipped]) print(daily_sales.head())逻辑说明reindex这一步很关键它保证每个 SKU 都有完整的日期序列没卖出去的日子补 0。如果不做这一步模型会误以为没记录的日子不存在导致学习到的“时间连续性”是断裂的。分位数剪枝用的是 99.5 分位这个值需要根据数据分布调整——如果发现剪完还是有明显离群点可以降到 99 分位。log1p 变换的意义在于把 0 映射为 0同时压缩大值的尺度这样 DNN 输出的预测值需要做expm1逆变换才能还原成真实销量。2.3 样本权重让模型“重视”那些卖得少的商品剪枝和 log1p 解决了尺度问题但还有一个问题是样本量的不均衡。有的 SKU 有完整的 365 天记录有的只上架了 30 天如果直接训练长期在售的 SKU 会贡献更多梯度。更麻烦的是部分 SKU 虽然销量低但生命周期短、补货窗口小预测错误成本更高。我一般的做法是在训练时给每个样本加一个权重权重由“该 SKU 的日均销量”决定。核心思路是不要让高销量 SKU 完全主导损失也不要让全零 SKU 被无视。常见做法是构造一个介于 0.5 到 2 之间的权重系数。# 计算每个 sku 在窗口内的日均销量 sku_daily_avg daily_sales.groupby(sku_id)[quantity_clipped].mean() # 权重: 日均销量越低权重越高但有限度 # log1p 后取倒数再归一化到 [0.5, 2] weights 1.0 / np.log1p(sku_daily_avg 1) weights weights / weights.median() # 以中位数为基准缩放 weights weights.clip(0.5, 2.0) daily_sales[sample_weight] daily_sales[sku_id].map(weights) print(weights.describe())参数说明这里用weights.median()做归一化基准而不是均值是因为长尾分布下均值会被少数高销量 SKU 拉高。clip(0.5, 2.0)算是经验值如果数据集里长短尾差异特别大可以放宽到[0.3, 3.0]。训练时把这个权重传给 Keras 的fit方法的sample_weight参数即可。这一步对最终的预测效果影响很大我自己对比过不加权重时模型几乎把所有长尾商品的预测值压到了同一个低水平加了权重后差异化明显出来了。3. 特征工程与 DNN 模型结构从滑窗序列到多层全连接3.1 特征怎么构造才不“漏”也不“杂”DNN 可以自动学习特征组合但不代表可以随手把原始特征都丢进去。长尾商品的销量受几个因素影响季节和星期几是周期性因素价格和促销是强干扰因素历史销量序列是自相关因素。所以特征至少分三组。第一组是时间特征星期几、月份、是否月初、距离上次促销的天数。第二组是商品静态特征价格分桶、类目 id 的 embedding、上架天数。第三组是滚动统计特征过去 7 天销量均值、过去 7 天销量标准差、过去 14 天销量最大值、过去 3 天为 0 的天数。这里有个重要的边界问题滚动特征只能用预测时刻之前的数据绝不能混入未来信息。这里有一个点需要特别提醒做滑窗特征时最干净的方式是“扩展窗口 滞后”的组合。即特征取的是t-1, t-2, ...时刻的数据而不是t时刻的因为t时刻的真实销量还没发生如果用了当天的数据去预测当天就是典型的时间泄露。# 构造滚动特征只用历史数据 feature_cols [] for window in [3, 7, 14]: daily_sales[fsales_mean_{window}d] daily_sales.groupby(sku_id)[quantity_clipped].transform( lambda x: x.shift(1).rolling(window, min_periods1).mean() ) daily_sales[fsales_std_{window}d] daily_sales.groupby(sku_id)[quantity_clipped].transform( lambda x: x.shift(1).rolling(window, min_periods1).std().fillna(0) ) daily_sales[fsales_max_{window}d] daily_sales.groupby(sku_id)[quantity_clipped].transform( lambda x: x.shift(1).rolling(window, min_periods1).max() ) daily_sales[fzero_days_{window}d] daily_sales.groupby(sku_id)[quantity_clipped].transform( lambda x: x.shift(1).rolling(window, min_periods1).apply(lambda y: (y 0).sum(), rawTrue) ) # 滞后特征 daily_sales[lag_1] daily_sales.groupby(sku_id)[quantity_clipped].shift(1) daily_sales[lag_7] daily_sales.groupby(sku_id)[quantity_clipped].shift(7) # 删除前 14 天的数据滚动窗口未填满 daily_sales daily_sales[daily_sales[order_date] daily_sales[order_date].min() pd.Timedelta(days14)]参数说明shift(1)是核心保证每一行特征只包含历史信息。滚动窗口的min_periods1允许早期数据用部分窗口计算但删除前 14 天的数据之后实际训练集中每个样本都有完整的 14 天历史窗口。apply计算零值天数时用了rawTrue是为了提速数据量大的时候这个细节很重要。3.2 DNN 结构设计与 Keras 实现对表格型数据做回归DNN 结构不用太复杂。三层到四层全连接足够每层神经元数量逐层递减配合 BatchNorm 和 Dropout 防止过拟合。输入层维度等于特征数量输出层是 1 个神经元激活函数用linear损失函数用 Huber Loss 而不是 MSE因为 Huber 对离群点更鲁棒——长尾数据即使做了剪枝仍然会有一些异常波动。这里要解释一下选择 DNN 而不是 LSTM 或 Transformer 的原因。长尾商品的销量序列很短通常只有几十天到一百多天而且缺失值多、噪声大序列模型容易过拟合。DNN 配合滚动特征的做法是工业界更常见的选择训练快、推理快、无需处理序列依赖。import tensorflow as tf from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Dense, BatchNormalization, Dropout, Embedding, Flatten, Concatenate def build_dnn(num_numeric_features, num_categories, embedding_dim8): # 数值特征输入 numeric_input Input(shape(num_numeric_features,), namenumeric_input) # 类目特征输入如 category_id cat_input Input(shape(1,), namecat_input) cat_embedding Embedding(input_dimnum_categories, output_dimembedding_dim)(cat_input) cat_flat Flatten()(cat_embedding) # 拼接 concat Concatenate()([numeric_input, cat_flat]) # 三层全连接 x Dense(128, activationrelu)(concat) x BatchNormalization()(x) x Dropout(0.3)(x) x Dense(64, activationrelu)(x) x BatchNormalization()(x) x Dropout(0.2)(x) x Dense(32, activationrelu)(x) x BatchNormalization()(x) output Dense(1, activationlinear, namesales_output)(x) model Model(inputs[numeric_input, cat_input], outputsoutput) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losstf.keras.losses.Huber(delta1.0), metrics[tf.keras.metrics.MeanAbsoluteError()] ) return model参数说明Embedding层把类目 id 映射成稠密向量避免了OneHot带来的维度爆炸。Dropout(0.3)是第一层的丢弃率第二层降到0.2这是经验值——如果训练集很小两个 Dropout 都提到0.4效果更好。Huber Loss 的delta1.0表示误差小于 1 时用 MSE大于 1 时用 MAE 的线性形式这个值与 log1p 变换后的数据尺度匹配因为变换后的销量基本落在[0, 2.5]区间。学习率1e-3是 Adam 的默认推荐值后续可以用回调逐步降低。4. 训练流程与调参实验验证集切分、回调函数和关键超参数4.1 验证集怎么切才不踩“时间泄露”的坑这个问题值得单独讲。很多人在做时间序列预测时沿用随机切分把数据集 shuffle 后按比例切训练集和测试集这在销量预测里是致命的错误。因为同一 SKU 的相邻日期样本高度相关随机切分会把时间上相邻的数据同时分到训练集和测试集模型等于“看到了未来”验证指标会虚高到离谱。正确的做法是按时间切分用前 80% 的时间段做训练中间 10% 做验证最后 10% 做测试。而且 SKU 要保证在训练集中出现过的才能在测试集中出现——新 SKU 的冷启动是另一个问题这里先不展开。# 按时间切分 daily_sales daily_sales.sort_values(order_date) date_list daily_sales[order_date].unique() train_cutoff date_list[int(len(date_list) * 0.8)] val_cutoff date_list[int(len(date_list) * 0.9)] train_df daily_sales[daily_sales[order_date] train_cutoff] val_df daily_sales[(daily_sales[order_date] train_cutoff) (daily_sales[order_date] val_cutoff)] test_df daily_sales[daily_sales[order_date] val_cutoff] # 特征与标签分离 feature_columns [col for col in daily_sales.columns if col.startswith(sales_) or col.startswith(lag_)] # 加上时间特征和价格特征假设已构造 feature_columns [dayofweek, month, price_log] X_train_num train_df[feature_columns].values X_train_cat train_df[category_id].values y_train train_df[quantity_log].values w_train train_df[sample_weight].values # 同理处理验证集...4.2 训练回调与早停策略训练 DNN 回归模型最容易翻车的地方是过拟合和验证集 loss 震荡。我之前用固定 epoch 训练经常出现验证集 loss 在某个 epoch 之后开始上升的情况。解决方法是加EarlyStopping和ReduceLROnPlateau两个回调前者在验证 loss 不再下降时提前终止后者在 loss 进入平台期时自动降低学习率。from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint early_stop EarlyStopping( monitorval_loss, patience15, restore_best_weightsTrue, verbose1 ) reduce_lr ReduceLROnPlateau( monitorval_loss, factor0.5, patience5, min_lr1e-6, verbose1 ) checkpoint ModelCheckpoint( best_dnn_model.h5, monitorval_loss, save_best_onlyTrue, verbose1 ) history model.fit( [X_train_num, X_train_cat], y_train, sample_weightw_train, validation_data([X_val_num, X_val_cat], y_val, w_val), epochs100, batch_size512, callbacks[early_stop, reduce_lr, checkpoint], verbose1 )参数说明patience15表示验证 loss 连续 15 个 epoch 不下降就停止这个值不是拍脑袋定的。结合ReduceLROnPlateau的patience5整体逻辑是先降学习率尝试跳出平台期如果 5 次降学习率后仍然没有改善再等 10 个 epoch 就终止——15 的 patience 给了降学习率充分发挥的空间。batch_size512适合中等规模的数据几万到几十万样本如果数据量超过百万可以调到 1024 或 2048 加速训练但要注意 BatchNorm 在小 batch 下统计量会不稳所以不建议为了省内存把 batch 降得太小。4.3 评估指标不要只看 RMSE要看分位数误差长尾销量预测里RMSE 会严重偏向那些销量大的样本。一个 SKU 预测误差 100 件把 RMSE 拉高但事实上可能只是这个 SKU 当天的异常大促造成的。我一般同时看三个指标整体 WMAE、低销量商品的 WMAE、以及零销量商品的召回率预测为 0 但实际卖出了多少。def wmae(y_true, y_pred, y_origin): y_origin: 真实销量原始值 对低销量商品加权低销量权重更高 error np.abs(y_origin - np.expm1(y_pred)) weight 1.0 / (1.0 y_origin) return np.sum(weight * error) / np.sum(weight) # log 域预测还原 y_test_pred_log model.predict([X_test_num, X_test_cat]) y_test_pred np.expm1(y_test_pred_log) y_test_actual np.expm1(y_test) print(fWMAE: {wmae(y_test_actual, y_test_pred_log, y_test_actual):.4f}) # 低销量子集评估 low_volume_mask y_test_actual 3 print(f低销量 WMAE: {wmae(y_test_actual[low_volume_mask], y_test_pred_log[low_volume_mask], y_test_actual[low_volume_mask]):.4f})这个评估函数我给过好几个团队用对比下来比 RMSE 更符合业务直觉销量为 0 的 SKU 预测成 1 件误差权重是 1销量 100 的 SKU 预测成 90误差权重只有 1/101几乎是零惩罚——这正好对应供应链场景里“发多发少都不好但缺货比多备货问题更大”的业务取向。5. 长尾商品销量 DNN 预测的 5 个常见坑与排查方法5.1 全零序列导致模型输出恒为 0现象很多长尾商品在训练集里销量全是 0模型对这类 SKU 的预测完全为 0即使实际发生了突然的爆量。原因全零序列的滚动特征均值、最大值、标准差全是 0DNN 学到的映射关系就是“特征全 0 → 输出 0”。这在数学上没毛病但业务上我们希望模型哪怕给个 0.1 的期望值也好过完全没预测。解决不要在训练集里直接丢弃全零序列。给这类样本一个“最小销量底数”比如把 log1p 变换前的销量用max(quantity, 0.5)替换让变换后的目标值有微小差异模型就不会完全塌缩到 0。5.2 验证集 loss 为 0但线上预测一塌糊涂——时间泄露现象验证集上 MAE 只有 0.01模型几乎完美但上线后预测值和真实值差得离谱。原因这是最典型的“黑匣子”陷阱。上文提到的时间泄露问题哪怕在按时间切分后仍然可能发生——如果没有把“当天的价格、当天的促销标记”从特征中剔除模型直接学到了“价格低于 50 且促销标记为 1 时当天销量高”而实际预测时当天价格未知。解决把所有预测时刻“不可知”的变量全部滞后一天。价格用t-1或最近一次挂牌价促销标记用“距离促销结束的天数”而非“当天是否促销”。排查方法很简单随机挑几个验证集样本看特征里有没有哪一列和标签的相关系数超过 0.9有的话就是泄露了。5.3 Embedding 维度太高导致小数据过拟合现象类目有 5000 个embedding_dim 设成 64训练集只有 10 万条验证 loss 不断上升。原因Embedding 维度本质上是给每个类目学习一个 64 维的向量5000 × 64 32 万个参数小数据集根本撑不起这么多自由参数。解决把 embedding_dim 降到 4 或 8。可以先从 4 开始观察验证集 WMAE 是否随维度上升而下降如果没变化或变差就说明特征冗余了。经验值是 embedding_dim ≈ 类目数量的四次方根5000 类对应大概 8 维。5.4 训练 loss 正常但预测值全是负数现象模型训练完成后预测结果里有大量负数明显不可能是销量。原因log1p 变换后目标值域是[0, ∞)但linear输出层没有限制下限DNN 在某些特征组合下输出负值逆变换后的预测自然就是负的。解决最直接的办法是输出层改用softplus激活函数数学形式是log(1 exp(x))保证输出永远大于 0。代价是在训练初期的梯度传播上不如 linear 直接但相对于处理负数预测的麻烦这点代价很值。from tensorflow.keras.layers import Activation # 替换输出层 output Dense(1)(x) output Activation(softplus)(output)5.5 训练后权重文件保存失败或加载不匹配现象model.save(best_dnn_model.h5)之后换到另一台机器加载时提示 shape 不匹配或自定义层报错。原因检查是不是用了自定义的lambda函数做预处理如果有加载时必须传入custom_objects。解决把预处理逻辑独立到模型外模型里只放标准层。保存时用.h5不带自定义对象加载时严格固定特征列顺序最好把feature_columns列表用 json 存一份跟权重文件放一起。这是我踩过最实在的坑——特征列顺序偏了一位整个预测结果静默错乱而且极难发现。6. 增量训练与一键预测脚本把模型落地到日常补货流程模型训练完不是终点还要解决两个问题一是模型如何随新数据更新二是业务同学怎么方便地用起来。我最终落地的方案是写一个增量训练脚本每天定时跑一次用新增数据微调模型然后把预测结果输出成 Excel供采购团队直接执行。增量训练的核心是“冻结底层、微调上层”。长尾数据的模式相对稳定底层特征抽象能力不用每天重学只重新训练最后两层并降低学习率既能跟上销量趋势变化又不会浪费时间在特征学习上。# 加载训练好的模型 model load_model(best_dnn_model.h5) # 冻结前两层 for layer in model.layers[:6]: layer.trainable False # 降低学习率微调 model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-5), losstf.keras.losses.Huber(delta1.0), metrics[tf.keras.metrics.MeanAbsoluteError()] ) # 用最近 30 天的新数据微调 new_data load_recent_daily_sales(days30) X_new_num, X_new_cat, y_new, w_new prepare_features(new_data) model.fit( [X_new_num, X_new_cat], y_new, sample_weightw_new, epochs5, batch_size256, verbose0 )预测输出时的格式设计值得下功夫。业务人员不关心模型结构和参数他们需要的是“哪个 SKU、建议补多少、置信度如何”。我的输出表包含五列SKU 编码、预测明日销量原始值、预测未来 7 天总销量、基于预测的补货建议预测销量 × 1.2 修正系数、置信度标记高/中/低按模型预测的标准差归一化后计算。置信度是这个方案里业务方最看重的字段高置信度可以直接走自动补货单低置信度则需要人工复核。模型的预测分位数也会输出比如输出 0.1 分位和 0.9 分位作为悲观值与乐观值。实际操作里为了抑制长尾销量偏斜带来的影响直接用预测值补货容易偏大用 0.6 分位值反而更稳。这个参数我调了近两周才确定下来——不同品类的偏斜度不一样鞋服类偏斜最严重3C 配件类相对平滑最好每个大类单独标定一次系数。最后说一个我的习惯每次增量训练完成后我会把验证集 WMAE 和低销量 WMAE 记录到 CSV 文件里如果发现连续一周低销量 WMAE 在上升就说明模型对长尾商品的能力在退化需要重新做特征工程或增加网络容量了——这套监控流程帮我避开了不只一次模型静默退化的尴尬。做销量预测这个方向老老实实把数据捋干净、把验证切对、把坑挨个踩一遍出来的结果就足够支撑一线补货决策了希望帮到你。本文还有配套的精品资源点击获取