← 回到阅读流

电商商品需求预测:时间序列入门案例

电商商品需求预测:时间序列入门案例

继续读下去

更多文章,公众号见。

如果你喜欢这些文字,欢迎关注微信公众号「程序员学长」。

「程序员学长」
程序员学长 微信公众号二维码
认识小寒

加个微信,聊聊。

如果你也关注 AI、阅读和思考,欢迎添加我的个人微信。

「微信号:dataalgo」
微信号:dataalgo 个人微信二维码

电商商品需求预测:时间序列入门案例

适合小白的完整实战分享:从原始订单表到可落地的需求预测。

这个案例要做什么?

我们手里有一家英国电商的历史订单数据(Online Retail II),时间跨度大约是 2009-12 到 2011-12。

业务问题很直接:

根据过去每天卖出了多少件商品,预测未来一段时间的总需求量。

有了需求预测,库存、采购、仓储人力安排都会更有依据。

我们会跟着这条主线走

  1. 明确预测目标:预测什么?按天还是按周?
  2. 整理时间字段:把订单变成真正的时间序列
  3. 处理数据问题:退货、缺失、异常值
  4. 观察数据规律:趋势、季节性、噪声
  5. 划分训练/测试集:用“过去”训练,用“未来”检验
  6. 训练并预测:基线 → 指数平滑 → ARIMA → 随机森林
  7. 评估和比较:MAE / RMSE / MAPE
记住一句话:先理解数据,再选择模型;先验证简单方法,再增加复杂度。

0. 环境准备

请先安装依赖(在本案例目录下执行):

pip install -r requirements.txt

然后用 Jupyter 打开本 notebook。

# 基础库
import warnings
warnings.filterwarnings('ignore')

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from pathlib import Path

# 统计模型
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf

# 机器学习
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error
from IPython.display import display

# 图表风格:中文友好 + 清晰可读
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'PingFang SC', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['figure.figsize'] = (12, 4)
sns.set_style('whitegrid')

# 1. 设置中文字体(这里列出了 Windows、Mac 和 Linux 的常见中文字体,系统会自动按顺序匹配)
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'STHeiti', 'Heiti TC', 'sans-serif']

# 2. 正常显示负号
plt.rcParams['axes.unicode_minus'] = False

print('环境准备完成 ✅')

环境准备完成 ✅

1. 明确预测目标

项目本案例设定为什么这样设
预测对象全站日销量(件数)最直观的“需求量”
时间频率按天便于观察周内规律(周一到周日)
预测长度未来约 60 天用最后 60 天当测试集,模拟真实业务
业务用途库存补货 / 运营排班预测偏一点可以接受,但不能乱猜
小白提示:时间序列预测不是“算出一个绝对正确的数字”,而是尽量接近真实值,帮助做更好的决策。

2. 读取原始数据

原始文件 online_retail_II.xlsx 有两个工作表:

每一行是一条订单明细(某张发票里买了某个商品多少件),还不是时间序列。
我们后面会把它按天汇总成“每天卖了多少件”。

第一次读 Excel 可能需要 30~60 秒,属于正常现象。
如果你已经跑过后面生成 daily_demand.csv 的单元格,也可以直接从 CSV 快速加载。
DATA_DIR = Path('.')
XLSX_PATH = DATA_DIR / 'online_retail_II.xlsx'
CSV_PATH = DATA_DIR / 'daily_demand.csv'

# 优先读原始 Excel,保证演示完整流程
print('正在读取 Excel,请稍候...')
df_2009 = pd.read_excel(XLSX_PATH, sheet_name='Year 2009-2010')
df_2010 = pd.read_excel(XLSX_PATH, sheet_name='Year 2010-2011')

raw = pd.concat([df_2009, df_2010], ignore_index=True)
print('合并后原始行数:', len(raw))
print('字段:', list(raw.columns))
raw.head()

正在读取 Excel,请稍候...
合并后原始行数: 1067371
字段: ['Invoice', 'StockCode', 'Description', 'Quantity', 'InvoiceDate', 'Price', 'Customer ID', 'Country']

Invoice StockCode Description Quantity InvoiceDate Price Customer ID Country
0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 2009-12-01 07:45:00 6.95 13085.0 United Kingdom
1 489434 79323P PINK CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom
2 489434 79323W WHITE CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom
3 489434 22041 RECORD FRAME 7" SINGLE SIZE 48 2009-12-01 07:45:00 2.10 13085.0 United Kingdom
4 489434 21232 STRAWBERRY CERAMIC TRINKET BOX 24 2009-12-01 07:45:00 1.25 13085.0 United Kingdom

字段通俗解释

字段含义(白话)
Invoice发票号 / 订单号。以 C 开头通常表示取消/退货
StockCode商品编码
Description商品名称
Quantity购买数量(负数多为退货)
InvoiceDate下单时间(这是时间序列的关键字段)
Price单价
Customer ID客户编号(可能缺失)
Country客户所在国家
# 快速看一眼数据质量
print('时间范围:', raw['InvoiceDate'].min(), '→', raw['InvoiceDate'].max())
print('\n缺失值统计:')
print(raw.isnull().sum())
print('\nQuantity 描述:')
print(raw['Quantity'].describe())
print('\n国家 Top5:')
print(raw['Country'].value_counts().head())

时间范围: 2009-12-01 07:45:00 → 2011-12-09 12:50:00

缺失值统计:
Invoice 0
StockCode 0
Description 4382
Quantity 0
InvoiceDate 0
Price 0
Customer ID 243007
Country 0
dtype: int64

Quantity 描述:
count 1.067371e+06
mean 9.938898e+00
std 1.727058e+02
min -8.099500e+04
25% 1.000000e+00
50% 3.000000e+00
75% 1.000000e+01
max 8.099500e+04
Name: Quantity, dtype: float64

国家 Top5:
Country
United Kingdom 981330
EIRE 17866
Germany 17624
France 14330
Netherlands 5140
Name: count, dtype: int64

3. 处理数据问题(清洗)

电商订单常见坑:

  1. 退货/取消单:Invoice 以 C 开头,或 Quantity < 0
  2. 异常价格:Price <= 0(免费/错误记录)
  3. 缺描述:部分商品没有 Description(本案例预测总量,影响不大)

清洗原则:保留真实正向成交,再按天汇总需求。

df = raw.copy()

# 1) 去掉取消单
before = len(df)
df = df[~df['Invoice'].astype(str).str.startswith('C')]
print(f'去掉取消单: {before} → {len(df)}')

# 2) 只保留正数量、正价格
before = len(df)
df = df[(df['Quantity'] > 0) & (df['Price'] > 0)]
print(f'去掉非正数量/价格: {before} → {len(df)}')

# 3) 整理时间字段
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
df = df.sort_values('InvoiceDate')
df['date'] = df['InvoiceDate'].dt.normalize()  # 只保留年月日
df['Revenue'] = df['Quantity'] * df['Price']

print('\n清洗后时间范围:', df['date'].min().date(), '→', df['date'].max().date())
print('清洗后行数:', len(df))
df.head(3)

去掉取消单: 1067371 → 1047877
去掉非正数量/价格: 1047877 → 1041670

清洗后时间范围: 2009-12-01 → 2011-12-09
清洗后行数: 1041670

Invoice StockCode Description Quantity InvoiceDate Price Customer ID Country date Revenue
0 489434 85048 15CM CHRISTMAS GLASS BALL 20 LIGHTS 12 2009-12-01 07:45:00 6.95 13085.0 United Kingdom 2009-12-01 83.4
1 489434 79323P PINK CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom 2009-12-01 81.0
2 489434 79323W WHITE CHERRY LIGHTS 12 2009-12-01 07:45:00 6.75 13085.0 United Kingdom 2009-12-01 81.0

4. 把订单明细 → 时间序列

时间序列需要两样东西:

注意:原始数据里不是每一天都有订单(尤其是周末可能为 0)。
如果漏掉这些日期,模型会误以为“相邻两天中间只隔了 1 天”,其实可能隔了周末。

# 按天汇总:需求量、销售额、订单数
daily = (
    df.groupby('date')
      .agg(
          demand=('Quantity', 'sum'),
          revenue=('Revenue', 'sum'),
          orders=('Invoice', 'nunique'),
      )
      .sort_index()
)

# 补齐缺失日期(没有交易记为 0)
full_idx = pd.date_range(daily.index.min(), daily.index.max(), freq='D')
daily = daily.reindex(full_idx)
daily[['demand', 'revenue', 'orders']] = daily[['demand', 'revenue', 'orders']].fillna(0)
daily.index.name = 'date'
daily = daily.asfreq('D')  # 明确告诉模型:这是按天连续的数据

# 方便后面分析,加上星期几
daily['weekday'] = daily.index.dayofweek  # 0=周一 ... 6=周日
daily['weekday_name'] = daily.index.day_name()

# 缓存,方便下次快速加载
daily.to_csv(CSV_PATH)
print('已保存日度需求序列 →', CSV_PATH.resolve())
print('序列长度:', len(daily), '天')
print('零销量天数:', int((daily['demand'] == 0).sum()))
daily.head()

已保存日度需求序列 → /Users/gd-npc-1176/Desktop/时间序列预测/时间序列预测案例分享/1.电商商品需求预测/daily_demand.csv
序列长度: 739 天
零销量天数: 135

demand revenue orders weekday weekday_name
date
2009-12-01 26204.0 54513.50 119.0 1 Tuesday
2009-12-02 31896.0 63352.51 115.0 2 Wednesday
2009-12-03 49243.0 74037.91 124.0 3 Thursday
2009-12-04 21325.0 40732.92 89.0 4 Friday
2009-12-05 5119.0 9803.05 30.0 5 Saturday

(可选)下次直接从 CSV 快速加载

如果已经生成过 daily_demand.csv,可以跳过读 Excel,加快演示:

daily = pd.read_csv('daily_demand.csv', parse_dates=['date'], index_col='date').asfreq('D')
daily['weekday'] = daily.index.dayofweek
daily['weekday_name'] = daily.index.day_name()
print(daily.shape)
daily.head()

5. 观察数据规律(EDA)

先画图,再谈模型。我们重点看三件事:

  1. 整体走势:有没有长期上升/下降?
  2. 季节性:周末是否更低?年底是否更高?
  3. 异常点:有没有突然爆高/爆低?
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=False)

# 1) 全日销量走势
axes[0].plot(daily.index, daily['demand'], color='#2c7fb8', linewidth=1)
axes[0].set_title('每日商品需求量(件数)')
axes[0].set_ylabel('件数')

# 2) 7 日移动平均:把噪声抹平一点,更容易看趋势
axes[1].plot(daily.index, daily['demand'], color='lightgray', linewidth=0.8, label='原始日销量')
axes[1].plot(daily.index, daily['demand'].rolling(7).mean(), color='#d95f0e', linewidth=2, label='7日移动平均')
axes[1].set_title('趋势更清晰:7日移动平均')
axes[1].legend()
axes[1].set_ylabel('件数')

# 3) 周内模式:周一到周日平均销量
weekday_avg = daily.groupby('weekday')['demand'].mean()
axes[2].bar(['周一','周二','周三','周四','周五','周六','周日'], weekday_avg.values, color='#31a354')
axes[2].set_title('一周内平均需求(看周季节性)')
axes[2].set_ylabel('平均件数')

plt.tight_layout()
plt.show()

​

png

​

从图里你大概会看到

这些都是正常的业务特征,不是“数据坏了”。

# 再看月份季节性
monthly = daily['demand'].resample('M').sum()

plt.figure(figsize=(12, 4))
plt.plot(monthly.index, monthly.values, marker='o', color='#756bb1')
plt.title('每月总需求量')
plt.ylabel('件数')
plt.xlabel('月份')
plt.tight_layout()
plt.show()

print('月度需求描述:')
print(monthly.describe())

​

png

​

月度需求描述:
count 25.000000
mean 456812.200000
std 128017.774098
min 283555.000000
25% 382781.000000
50% 401759.000000
75% 540198.000000
max 754507.000000
Name: demand, dtype: float64

6. 时间序列分解:趋势 + 季节性 + 噪声

加法分解可以写成:

原始数据 ≈ 趋势 + 季节性 + 噪声

日度数据常见的固定周期是 7 天(一周)。

# 为了分解更稳定,这里用 7 日移动平均后的序列也可以;
# 入门案例直接对原始 demand 做 period=7 的分解。
decomp = seasonal_decompose(daily['demand'], model='additive', period=7)

fig = decomp.plot()
fig.set_size_inches(14, 10)
fig.suptitle('时间序列分解(周期=7天)', y=1.02, fontsize=14)
plt.tight_layout()
plt.show()

​

png

​

怎么读这四张图?

如果 Seasonal 很明显,后面用 Holt-Winters(带季节项) 就很合理。

7. 划分训练集和测试集(千万别随机打乱!)

时间序列必须按时间切:

继续读下去

更多文章,公众号见。

如果你喜欢这些文字,欢迎关注微信公众号「程序员学长」。

「程序员学长」
程序员学长 微信公众号二维码
认识小寒

加个微信,聊聊。

如果你也关注 AI、阅读和思考,欢迎添加我的个人微信。

「微信号:dataalgo」
微信号:dataalgo 个人微信二维码

本案例:用最后 60 天 做测试集。

TEST_DAYS = 60

train = daily.iloc[:-TEST_DAYS].copy()
test = daily.iloc[-TEST_DAYS:].copy()

y_train = train['demand']
y_test = test['demand']

print(f'训练集: {y_train.index.min().date()} → {y_train.index.max().date()}  ({len(y_train)} 天)')
print(f'测试集: {y_test.index.min().date()} → {y_test.index.max().date()}  ({len(y_test)} 天)')

plt.figure(figsize=(14, 4))
plt.plot(y_train.index, y_train, label='训练集', color='#2c7fb8')
plt.plot(y_test.index, y_test, label='测试集(未来)', color='#e34a33')
plt.axvline(y_test.index[0], color='gray', linestyle='--', label='分割点')
plt.title('训练集 / 测试集划分')
plt.legend()
plt.tight_layout()
plt.show()

训练集: 2009-12-01 → 2011-10-10 (679 天)
测试集: 2011-10-11 → 2011-12-09 (60 天)

png

8. 评估指标(怎么判断预测好不好)

指标白话理解特点
MAE平均错了多少件最好懂,单位=件数
RMSE更惩罚大错误大偏差会被放大
MAPE平均错了百分之多少便于沟通;真实值接近 0 时要小心

指标越小通常越好,但最终还要看业务:少备货和多备货的代价可能不同。

def mape(y_true, y_pred):
    """平均绝对百分比误差。真实值为 0 的点会跳过,避免除零。"""
    y_true = np.asarray(y_true, dtype=float)
    y_pred = np.asarray(y_pred, dtype=float)
    mask = y_true != 0
    if mask.sum() == 0:
        return np.nan
    return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100


def evaluate(y_true, y_pred, model_name):
    mae = mean_absolute_error(y_true, y_pred)
    rmse = mean_squared_error(y_true, y_pred) ** 0.5
    mape_v = mape(y_true, y_pred)
    return {
        '模型': model_name,
        'MAE': round(mae, 2),
        'RMSE': round(rmse, 2),
        'MAPE(%)': round(mape_v, 2) if pd.notna(mape_v) else np.nan,
    }


results = []  # 用来收集各模型成绩单
print('评估函数准备完成 ✅')

评估函数准备完成 ✅

def plot_forecast(y_train, y_test, y_pred, title):
    """统一的预测对比图:历史 + 真实未来 + 预测未来"""
    plt.figure(figsize=(14, 4))
    # 为了图更清晰,只展示训练末尾一段
    tail = y_train.iloc[-120:]
    plt.plot(tail.index, tail, label='历史(训练末段)', color='#9ecae1')
    plt.plot(y_test.index, y_test, label='真实值(测试)', color='#2c7fb8', linewidth=2)
    plt.plot(y_test.index, y_pred, label='预测值', color='#e34a33', linewidth=2)
    plt.title(title)
    plt.legend()
    plt.tight_layout()
    plt.show()

9. 先做基线模型(Baseline)

正式上复杂模型前,先做一个“笨办法”:

用昨天的销量,预测今天的销量(Naive / 上一时刻法)

如果复杂模型连这个都打不过,说明还没真正学到规律。

# 基线1:上一时刻(Naive)
# 测试集第 i 天的预测 = 测试集第 i-1 天真实值;第一天用训练集最后一天
naive_pred = y_test.shift(1)
naive_pred.iloc[0] = y_train.iloc[-1]

# 基线2:过去 7 天均值(简单平滑)
ma7_pred = []
history = list(y_train.values)
for i in range(len(y_test)):
    ma7_pred.append(np.mean(history[-7:]))
    history.append(y_test.iloc[i])  # 滚动:逐步纳入已发生的真实值
ma7_pred = pd.Series(ma7_pred, index=y_test.index)

results.append(evaluate(y_test, naive_pred, '基线-上一时刻'))
results.append(evaluate(y_test, ma7_pred, '基线-7日均值'))

plot_forecast(y_train, y_test, naive_pred, '基线:上一时刻预测')
plot_forecast(y_train, y_test, ma7_pred, '基线:滚动7日均值预测')
pd.DataFrame(results)

​

png

​

png

模型 MAE RMSE MAPE(%)
0 基线-上一时刻 12737.22 16744.15 41.02
1 基线-7日均值 10607.46 14869.09 31.87

10. Holt-Winters 指数平滑(统计方法)

指数平滑的直觉:

越近的数据越重要,越远的数据权重越小。

Holt-Winters 同时考虑:

日度数据:seasonal_periods=7

hw_model = ExponentialSmoothing(
    y_train,
    trend='add',
    seasonal='add',
    seasonal_periods=7,
)
hw_fit = hw_model.fit(optimized=True)
hw_pred = hw_fit.forecast(len(y_test))

results.append(evaluate(y_test, hw_pred, 'Holt-Winters'))
plot_forecast(y_train, y_test, hw_pred, 'Holt-Winters 预测(季节周期=7)')
pd.DataFrame(results)

​

png

​

模型 MAE RMSE MAPE(%)
0 基线-上一时刻 12737.22 16744.15 41.02
1 基线-7日均值 10607.46 14869.09 31.87
2 Holt-Winters 7342.72 12054.38 26.14

11. ARIMA(统计方法)

ARIMA(p, d, q) 可以拆开理解:

入门案例先用一个简单设定 ARIMA(7, 1, 1)(考虑大约一周的自相关 + 一阶差分)。
真实项目里可以用 ACF/PACF 或自动选参工具细调。

# 先看一眼自相关,帮助理解“要不要看过去几天”
fig, axes = plt.subplots(1, 2, figsize=(12, 3))
plot_acf(y_train.diff().dropna(), lags=28, ax=axes[0])
plot_pacf(y_train.diff().dropna(), lags=28, ax=axes[1])
axes[0].set_title('ACF(差分后)')
axes[1].set_title('PACF(差分后)')
plt.tight_layout()
plt.show()

​

png

​

arima_model = ARIMA(y_train, order=(7, 1, 1))
arima_fit = arima_model.fit()
arima_pred = arima_fit.forecast(steps=len(y_test))

results.append(evaluate(y_test, arima_pred, 'ARIMA(7,1,1)'))
plot_forecast(y_train, y_test, arima_pred, 'ARIMA(7,1,1) 预测')
print(arima_fit.summary().tables[0])
pd.DataFrame(results)

​

png

​

SARIMAX Results
==============================================================================
Dep. Variable: demand No. Observations: 679
Model: ARIMA(7, 1, 1) Log Likelihood -7291.131
Date: Thu, 16 Jul 2026 AIC 14600.262
Time: 18:19:03 BIC 14640.935
Sample: 12-01-2009 HQIC 14616.008

Covariance Type: opg
==============================================================================

模型 MAE RMSE MAPE(%)
0 基线-上一时刻 12737.22 16744.15 41.02
1 基线-7日均值 10607.46 14869.09 31.87
2 Holt-Winters 7342.72 12054.38 26.14
3 ARIMA(7,1,1) 13540.49 17444.01 43.22

12. 随机森林(机器学习方法)

机器学习不能直接“吃”一条时间序列,需要先把它变成表格特征,例如:

重要:特征里只能用预测时刻之前已经知道的信息,否则就是“偷看未来”。
def make_lag_features(series, lags=(1, 2, 3, 7, 14), roll_windows=(7, 14)):
    """把时间序列变成监督学习特征表"""
    feat = pd.DataFrame({'y': series})
    for lag in lags:
        feat[f'lag_{lag}'] = series.shift(lag)
    for w in roll_windows:
        # shift(1) 保证滚动统计不包含当天,避免泄漏
        feat[f'roll_mean_{w}'] = series.shift(1).rolling(w).mean()
        feat[f'roll_std_{w}'] = series.shift(1).rolling(w).std()
    feat['weekday'] = series.index.dayofweek
    feat['month'] = series.index.month
    return feat.dropna()


# 用全序列构造特征,再按时间切分(注意:特征已用 shift,不会用到未来)
feat_all = make_lag_features(daily['demand'])
feat_train = feat_all.loc[feat_all.index <= y_train.index.max()]
feat_test = feat_all.loc[feat_all.index >= y_test.index.min()]

X_train = feat_train.drop(columns=['y'])
y_train_rf = feat_train['y']
X_test = feat_test.drop(columns=['y'])
y_test_rf = feat_test['y']

rf = RandomForestRegressor(
    n_estimators=300,
    max_depth=8,
    min_samples_leaf=3,
    random_state=42,
    n_jobs=-1,
)
rf.fit(X_train, y_train_rf)
rf_pred = pd.Series(rf.predict(X_test), index=y_test_rf.index)

results.append(evaluate(y_test_rf, rf_pred, '随机森林'))
plot_forecast(y_train, y_test_rf, rf_pred, '随机森林预测(滞后特征)')

# 看一眼哪些特征最重要
imp = pd.Series(rf.feature_importances_, index=X_train.columns).sort_values(ascending=False)
plt.figure(figsize=(10, 4))
imp.head(10).plot(kind='bar', color='#31a354')
plt.title('随机森林:特征重要性 Top10')
plt.ylabel('重要性')
plt.tight_layout()
plt.show()

pd.DataFrame(results)

​

png

​

png

模型 MAE RMSE MAPE(%)
0 基线-上一时刻 12737.22 16744.15 41.02
1 基线-7日均值 10607.46 14869.09 31.87
2 Holt-Winters 7342.72 12054.38 26.14
3 ARIMA(7,1,1) 13540.49 17444.01 43.22
4 随机森林 5654.41 10356.39 24.27

13. 模型对比与结论

把所有模型的成绩单摆在一起,看谁更适合这个业务场景。

scoreboard = pd.DataFrame(results).drop_duplicates(subset=['模型'], keep='last')
scoreboard = scoreboard.sort_values('MAE')
scoreboard = scoreboard.reset_index(drop=True)
scoreboard['排名'] = scoreboard.index + 1
scoreboard = scoreboard[['排名', '模型', 'MAE', 'RMSE', 'MAPE(%)']]
display(scoreboard)

best_name = scoreboard.iloc[0]['模型']
print(f'\n本轮测试集上表现最好的模型是:{best_name}')
print('(注意:不同切分窗口结果可能略有变化,分享时建议强调“流程”而不是死记某个数字)')
排名 模型 MAE RMSE MAPE(%)
0 1 随机森林 5654.41 10356.39 24.27
1 2 Holt-Winters 7342.72 12054.38 26.14
2 3 基线-7日均值 10607.46 14869.09 31.87
3 4 基线-上一时刻 12737.22 16744.15 41.02
4 5 ARIMA(7,1,1) 13540.49 17444.01 43.22

​

本轮测试集上表现最好的模型是:随机森林
(注意:不同切分窗口结果可能略有变化,分享时建议强调“流程”而不是死记某个数字)

# 把几个主要模型画在同一张图上对比(更适合做分享 PPT)
plt.figure(figsize=(14, 5))
plt.plot(y_test.index, y_test, label='真实值', color='black', linewidth=2.5)
plt.plot(y_test.index, naive_pred, label='基线-上一时刻', alpha=0.8)
plt.plot(y_test.index, hw_pred, label='Holt-Winters', alpha=0.8)
plt.plot(y_test.index, arima_pred, label='ARIMA', alpha=0.8)
plt.plot(rf_pred.index, rf_pred, label='随机森林', alpha=0.8)
plt.title('测试集预测对比')
plt.legend()
plt.tight_layout()
plt.show()

​

png

​

14. 用最优思路做“真正的未来预测”(示意)

前面测试集其实是“已知答案的考试”。
分享时还可以演示:用全部历史数据重训,预测未来 14 天。

这里用 Holt-Winters 做示意(部署时换成你成绩单里最好的模型即可)。

FUTURE_DAYS = 14

final_model = ExponentialSmoothing(
    daily['demand'],
    trend='add',
    seasonal='add',
    seasonal_periods=7,
).fit(optimized=True)

future_pred = final_model.forecast(FUTURE_DAYS)
future_index = pd.date_range(daily.index.max() + pd.Timedelta(days=1), periods=FUTURE_DAYS, freq='D')
future_pred.index = future_index

plt.figure(figsize=(14, 4))
plt.plot(daily.index[-90:], daily['demand'].iloc[-90:], label='近90天历史', color='#2c7fb8')
plt.plot(future_pred.index, future_pred, label='未来14天预测', color='#e34a33', marker='o')
plt.title('未来 14 天需求预测(Holt-Winters 示意)')
plt.legend()
plt.tight_layout()
plt.show()

future_df = pd.DataFrame({
    'date': future_pred.index.date,
    'predicted_demand': future_pred.round(0).astype(int).values,
})
future_df

​

png

​

date predicted_demand
0 2011-12-10 18702
1 2011-12-11 29884
2 2011-12-12 39672
3 2011-12-13 41527
4 2011-12-14 41018
5 2011-12-15 43980
6 2011-12-16 36230
7 2011-12-17 18885
8 2011-12-18 30067
9 2011-12-19 39855
10 2011-12-20 41710
11 2011-12-21 41201
12 2011-12-22 44163
13 2011-12-23 36413

15. 初学者最容易踩的坑

✅ 必须按时间前后切分

✅ 只在训练集上 fit,再 transform 测试集

✅ 用完整日日历 reindex,没有销量就填 0(或单独建模营业日)

✅ 先基线,再统计模型,再机器学习

✅ 关注测试集表现和业务可解释性

  1. ❌ 随机打乱后再 train_test_split
  2. ❌ 归一化时用了全量数据的最大最小值
  3. ❌ 漏掉没有交易的日期
  4. ❌ 一上来就上 LSTM
  5. ❌ 把噪声当成规律死记硬背

16. 进阶方向(分享加分项)

如果你还想继续打磨这个案例,可以尝试:

  1. 按周汇总再预测(波动更小,适合补货节奏)
  2. 只预测 Top 商品 / Top 品类,而不是全站总量
  3. 加入促销、节假日、价格等外部特征(更适合 XGBoost)
  4. 用滚动预测(walk-forward)做更稳的评估
  5. 数据量足够时再尝试 LSTM / GRU

本案例文件说明

文件作用
online_retail_II.xlsx原始订单数据
daily_demand.csv清洗后的日度需求序列(运行后生成)
requirements.txtPython 依赖
本 notebook完整分析与分享脚本
继续读下去

更多文章,公众号见。

如果你喜欢这些文字,欢迎关注微信公众号「程序员学长」。

「程序员学长」
程序员学长 微信公众号二维码
认识小寒

加个微信,聊聊。

如果你也关注 AI、阅读和思考,欢迎添加我的个人微信。

「微信号:dataalgo」
微信号:dataalgo 个人微信二维码