简介:面向供应链备货场景的长尾商品销量预测项目,基于Python与TensorFlow 1.13实现DNN模型,支持7天、30天和60天预测。资源定位清晰,适合电商供应链、数据挖掘初学者或需要搭建销量预测基线方案的开发者。包内共6个文件,包含5个Python脚本和1个Markdown说明文档,压缩包仅20KB,脚本涵盖训练、预测和单输出/多输出两个版本,代码轻量易读。项目详细展示了TensorBoard可视化用法、tf.train.Saver模型保存、训练/验证/测试集划分方法,以及自定义Early Stopping的完整逻辑——当验证集精度连续多个Epoch未提升时停止训练。此外还提供离线模型加载预测示例,通过import_meta_graph和get_operation_by_name实现模型复用,对理解TensorFlow低阶API落地很有帮助。目前已有126人学习参考。
1. 长尾商品销量预测为什么绕不开 DNN
在供应链系统里,长尾商品的预测是最反直觉的:主销品有足够历史序列跑 ARIMA,而长尾 SKU 往往只有零星的销售记录,却要在 7 天、30 天、60 天三个周期上给出备货建议。传统统计模型在稀疏和零膨胀序列上普遍过拟合,DNN 的优势不在于时间步推演,而是可以把品类、价格、促销、近期销量等特征揉进同一个非线性函数。这个源码包用低阶 TensorFlow API 实现了两套入口:single_output 和 multiple_output,覆盖训练、验证、早停、保存与离线预测。适合想动手看 TensorFlow 1.x 底层逻辑的供应链算法或数据分析师,也适合赶需求的同学直接改特征列。这里先不讨论用传统时序模型做对照,只看 DNN 这条线怎么落地。
2. 样本切分、特征构造与验证集的选择
在长尾销量预测里,最大的陷阱是“用随机切分评估时间序列模型”。如果沿用 sklearn 的 train_test_split,会让未来信息泄漏到训练集,验证指标虚高,正式上线后一碰就碎。项目源码默认按时间顺序切出训练、验证、测试三个集合,并把验证集专门用来选超参数和判断早停,测试集只在最终阶段跑一次。
2.1 训练集、验证集、测试集在长尾场景里的定位
| 数据集 | 核心用途 | 长尾场景的切分方式 | 常见占比 |
|---|---|---|---|
| 训练集 | 学习特征到销量的映射 | 最开始的完整时间区间 | 70% |
| 验证集 | 选隐藏层宽度、早停阈值等超参数 | 训练集之后的连续时间区间 | 15% |
| 测试集 | 评估最终泛化能力,只在最后使用 | 最后一段连续区间 | 15% |
这里要特别强调,验证集和测试集不能和训练集出现促销周期重叠,否则促销特征会被模型当作常态,导致预测偏高。长尾商品经常存在“平时零销量、促销一日爆发”的模式,如果切分时随机打乱,爆发样本未来信息会被训练集看到,验证集上的 loss 会好看得离谱,早停也完全失效。
2.2 特征工程:长尾 SKU 用什么特征喂给 DNN
我一般把特征分成三类:商品静态特征、近期销量统计特征、时间环境特征。商品静态特征包括价格、类别编码、是否新品;近期销量统计特征包括过去 7 天和 30 天的销量窗口;时间环境特征包括星期几、月份、是否促销。下面是一个可落地的特征构造函数,输出直接作为 DNN 的输入矩阵。
import pandas as pd import numpy as np def build_features(sku_profile, sku_daily_sale, target_date): # sku_profile: sku_id, price, category_id, promo # sku_daily_sale: sku_id, date, qty df = sku_profile.copy() # 统计 target_date 之前两个时间窗口的销量 recent = sku_daily_sale.groupby('sku_id').apply( lambda d: pd.Series({ 'sales_7d': d.loc[ (d['date'] > target_date - pd.Timedelta(days=7)) & (d['date'] <= target_date), 'qty'].sum(), 'sales_30d': d.loc[ (d['date'] > target_date - pd.Timedelta(days=30)) & (d['date'] <= target_date), 'qty'].sum(), }) ) df = df.merge(recent, on='sku_id') df['weekday'] = target_date.weekday() df['month'] = target_date.month df['promo'] = df['promo'].fillna(0) cols = ['price', 'category_id', 'sales_7d', 'sales_30d', 'weekday', 'month', 'promo'] return df[cols].astype(np.float32)这个函数把每个商品在某个日期上的当前状态变成一行样本,目标值是未来 7 天、30 天、60 天的真实销量。sales_7d和sales_30d本身相关度很高,但 DNN 能自己学出不同预测周期下两个窗口的权重,所以一起放进特征里没有坏处。category_id作为连续数值输入是一种简化,如果类目数量上千,建议先做一次目标编码,用各类目的历史平均销量替换原始 id,避免 DNN 强行学一个无顺序的整数编码。
2.3 按时间戳切分数据的代码
def split_by_time(df, train_days, val_days, test_days): # df 里必须有一列 date,并且已经按日期升序排列 dates = np.sort(df['date'].unique()) train_end = dates[train_days] val_end = dates[train_days + val_days] train = df[df['date'] < train_end] val = df[(df['date'] >= train_end) & (df['date'] < val_end)] test = df[df['date'] >= val_end] return train, val, test这里按天数切而不是按行数切,是因为长尾商品在不同日期上的活跃程度差异很大,按行数切会让验证集恰好落在大促周期里。按日期切能让三个集合覆盖完整的一天循环和促销分布。另外注意归一化参数只能在训练集上估计,比如销量均值方差、价格分位数,验证集和测试集必须直接使用训练集的统计量,不能重新计算。否则特征分布会被验证集信息污染,早停选出来的超参数也会带上偏差。
3. 低阶 TensorFlow DNN 训练:从 placeholder 到 Saver
这个源码包强调“低阶 API”,意味着不使用 tf.keras 封装,而是手写 placeholder、变量和 Session。这样做的好处是能清楚看到每一个张量的形状和流向,bad case 排查时可以直接打印中间层输出,不用猜 keras 内部发生了什么。
3.1 单输出与多输出分支的建模取舍
single_output 目录下每次只训练一个预测周期,比如单独预测未来 7 天销量。multiple_output 目录下共享 hidden layer,输出层直接展开成 3 个神经元,分别对应 7 天、30 天、60 天预测。从工程效率看,multiple_output 只需要一次前向计算就能拿到三个周期的结果,更贴合供应链系统同时备货的需求。从算法角度看,多个预测任务共享底层表示,本质是一种多任务学习,对长尾商品这种小样本场景有隐式正则效果。
| 网络配置 | 计划维度 | 预测一次得到的结果 | 适用场景 |
|---|---|---|---|
| single_output | 一个输出神经元 | 单个 horizon 预测值 | 独立优化某个周期 |
| multiple_output | 三个输出神经元 | 7/30/60 天预测值 | 供应链多周期备货 |
但多输出会导致 loss 来自三个量纲不同的误差项。我的做法是先对每个 horizon 的 label 分别做 z-score 归一化,再在 loss 中直接加和,这样训练早期不会出现 60 天误差值把 7 天梯度冲垮的情况。
3.2 训练循环中的关键节点
import tensorflow as tf FEATURE_DIM = 7 HIDDEN_1, HIDDEN_2 = 64, 32 HORIZONS = [7, 30, 60] learning_rate = 1e-3 xs = tf.placeholder(tf.float32, shape=[None, FEATURE_DIM], name='xs') ys = tf.placeholder(tf.float32, shape=[None, len(HORIZONS)], name='ys') dropout_keep = tf.placeholder(tf.float32, name='dropout_keep') h1 = tf.layers.dense(xs, HIDDEN_1, activation=tf.nn.relu, name='h1') h1_drop = tf.nn.dropout(h1, rate=1 - dropout_keep) h2 = tf.layers.dense(h1_drop, HIDDEN_2, activation=tf.nn.relu, name='h2') outputs = tf.layers.dense(h2, len(HORIZONS), name='outputs') loss = tf.reduce_mean(tf.reduce_sum(tf.square(outputs - ys), axis=1)) train_op = tf.train.AdamOptimizer(learning_rate).minimize(loss) merged = tf.summary.merge_all() saver = tf.train.Saver(max_to_keep=3) with tf.Session() as sess: sess.run(tf.global_variables_initializer()) writer = tf.summary.FileWriter('./tmp', sess.graph) for step in range(train_steps): batch_x, batch_y = next_batch(train_set) _, train_loss, summary = sess.run( [train_op, loss, merged], feed_dict={xs: batch_x, ys: batch_y, dropout_keep: 0.8}) if step % 500 == 0: writer.add_summary(summary, step) if step % 1000 == 0: saver.save(sess, './ckpt/dnn_model', global_step=step)tf.nn.dropout在 1.13 版本中的第二个参数是rate,表示丢弃比例,所以rate=1-0.8=0.2,和早期版本的keep_prob刚好相反。placeholder 的name参数一定要固定,尤其是xs、ys、dropout_keep和outputs,后续get_operation_by_name完全依赖这几个字符串。tf.summary.FileWriter('./tmp', sess.graph)把计算图结构写进事件文件,之后 tensorboard 才能画出数据流图。saver.save每 1000 步保存一次 checkpoint,后面做 early stop 恢复模型时,不会只能在训练结束时才能拿回上一次的最优参数。
3.3 TensorBoard 启动与读取
代码块里使用最新事件的写法是:
tensorboard --logdir=./tmp --port=6006--logdir要和FileWriter('./tmp')保持一致,目录下一般会看到events.out.tfevents.*文件。TensorBoard 默认监听 6006 端口,被占用时换--port=6007。浏览器打开后主要看SCALARS页面的 loss 曲线,如果训练 loss 在降到某个值后长期水平,而验证集 loss 没有同步下降,说明模型开始过拟合,早停该介入了。还有一个很实用的点是GRAPHS页面可以直接点击节点查看张量形状,当特征维度写错时,这一步比反复看报错信息快得多。
4. Early Stopping 的工程实现与超参选择
很多人把 early stop 理解成“验证集 loss 连续上升就停”,实际操作中验证集 loss 是震荡的,可能第 4 个 epoch 上升、第 5 个 epoch 又下降。直接按“连续上升”判断会过早停止,丢掉后面更好的解。源码里强调的 early stop 本质是:记录到目前为止最好的验证集精度,当连续 N 个 epoch 都没超过这个最佳值时,才认为模型不再改善。
4.1 早停的本质:验证集精度才是上限
训练 loss 会一直下降,因为模型在死记训练集中的噪声;验证集 loss 才是泛化能力的真实代理。在长尾商品数据里,很多特征组合只出现一次,模型很容易在训练集上“记住”某个 SKU 的促销爆发,却对验证集中另一个 SKU 的类似活动无感。早停的作用就是在验证集 loss 开始反弹之前,把模型参数停留在泛化能力最强的位置。这里要注意,早停只能用验证集,不能拿测试集判断,否则测试集也变成了超参数的一部分。
4.2 自定义 EarlyStopping 类
class EarlyStopping: def __init__(self, patience=10, min_delta=1e-4): self.patience = patience self.min_delta = min_delta self.best_score = None self.counter = 0 self.best_ckpt = None def track(self, val_loss, step): # 当前验证集 loss 明显优于历史最好,更新状态 if self.best_score is None or val_loss < self.best_score - self.min_delta: self.best_score = val_loss self.counter = 0 self.best_ckpt = f'./ckpt/best_{step}.ckpt' return False self.counter += 1 # 连续 patience 次没有进步,触发停止 return self.counter >= self.patiencetrack返回True时训练循环直接跳出。min_delta用来过滤微小的波动,比如验证集 loss 从 0.312 变成 0.3119,这种变化对业务预测没有意义,可以不算进步。best_ckpt记录的是当前最好模型对应的 step,训练跳出后要从这个 checkpoint 恢复,而不是用最后一步的参数。实际训练里我会在每个 epoch 结束时调用track(val_loss, epoch),而不是每个 step 都调用,否则会浪费大量时间在频繁保存模型上。
4.3 参数怎么选:一张表和三个排错点
| 参数 | 常见取值 | 说明 |
|---|---|---|
| patience | 5~20 | 长尾数据噪声大,建议 10 起 |
| min_delta | 1e-4~1e-3 | 小于该变化视为没有提高 |
| batch_size | 128 / 256 | 样本少时用小 batch,避免梯度噪声太大 |
| learning_rate | 1e-3 / 5e-4 | 平台期明显时降为 3e-4 |
如果第 2 个 epoch 就触发早停,优先检查验证集是否和训练集重叠,或者特征构造中是否使用了未来窗口数据。如果 patience 到了 30 还不触发,说明验证集 loss 还在缓慢下降,先调大 min_delta,而不是延长训练步数。另外,multiple_output模型里不同 horizon 的 loss 量纲不同,需要选择一个主早停指标,我一般用 30 天归一化后的 loss,因为 7 天噪声太大,60 天标签缺失率高,30 天在长尾场景中最稳定。
提示:早停的条件是“连续 N 次没有超过历史最佳”,不是“连续 N 次上升”。验证集 loss 小幅抖动很正常,min_delta 会帮你忽略这种抖动。
5. 离线预测:import_meta_graph 与 get_operation_by_name 的配合
训练结束后,预测脚本不需要重新构造变量和网络结构,只要读回 meta graph,再 restore 参数即可。这种做法的最大好处是训练脚本和预测脚本完全解耦,不会因为两套代码实现不同导致线上特征拼接不一致。
5.1 保存 checkpoint 后,预测时不需要重新建图
import tensorflow as tf def predict(sess, feat_np): graph = sess.graph xs = graph.get_operation_by_name('xs').outputs[0] outputs = graph.get_operation_by_name('outputs').outputs[0] keep = graph.get_operation_by_name('dropout_keep').outputs[0] feed = {xs: feat_np, keep: 1.0} y_pred = sess.run(outputs, feed_dict=feed) return y_pred with tf.Session() as sess: saver = tf.train.import_meta_graph('./ckpt/best_epoch_10.ckpt.meta') saver.restore(sess, './ckpt/best_epoch_10.ckpt') pred = predict(sess, sample_feature) print(pred)get_operation_by_name拿到的是 Operation,outputs[0]才是真正的 Tensor。如果训练代码里 placeholder 被包在tf.name_scope里,这里就必须写成scope/xs,所以训练脚本里的命名一定要固定,否则预测脚本会非常脆弱。预测时dropout_keep必须传 1.0,因为 dropout 只在训练时起作用,如果保持 0.8,多次预测同一批样本会得到不同的结果。
5.2 模型保存的最佳实践
训练时最好保存两份 checkpoint:一份是距离当前时间最近的一份,用于崩溃恢复;另一份是早停标记的最佳模型,用于正式预测。如果在训练过程中发现验证集 loss 出现历史最优,立刻保存一份带best_epoch后缀的 checkpoint,等训练结束后直接用最后保存的 best 文件,不要在预测脚本里再去遍历寻找最低 loss 对应的文件。另外,归一化的均值方差也要持久化到文件里,预测时重新加载训练集的统计量。很多长尾预测项目上线后效果变差,不是因为模型不对,而是预测脚本对销量做了新的归一化,导致输入特征分布和训练时不一致。
5.3 single_output 与 multiple_output 模块到底差在哪
以源码目录为参考,single_output下的train.py每次只能训练一个预测周期,如果三个周期都用它,需要完整跑三遍,特征缓存和归一化参数也各算各的。multiple_output下的train.py一次训练同时得到三个输出,predict.py直接拿到[7, 30, 60]三个预测值。从工程维护角度,多个输出头共享底层网络,参数总量更小,线上服务时只需要维护一个 graph 和一个 checkpoint。
最后补充一个我在预测脚本里常用的特征漂移检查。长尾商品的特征分布会随促销节奏变化,预测时把当前样本的sales_30d与训练集中的最大值做对比,如果超出比例过高,说明模型正在执行外推预测,结果应该被打折处理。
extrapolation_ratio = np.mean(feat_np[:, 3] > train_sales_30d_max) print(f'extrapolation_ratio={extrapolation_ratio:.2%}')当extrapolation_ratio超过 20% 时,我会在预测结果上乘以一个 0.9 的衰减系数,并输出警告到日志,让下游供应链系统知道这批备货建议置信度偏低。
本文还有配套的精品资源,点击获取