📈

論文実装:小売業における売上予測精度向上

に公開

論文実装:小売業における売上予測精度向上

今回の論文

Enhancing Retail Sales Forecasting with Optimized Machine Learning Modelsという論文を読みました。

まず、論文の内容ですが、ざっくり次のような感じです。(英語があまり得意ではないので、違っていたらご指摘ください。)

概要

本論文は、小売業における売上予測精度を向上させるために、機械学習モデルを最適化する手法を検討した研究である。特に、予測精度の向上とモデルの汎用性確保を目的として、複数のアルゴリズムを比較・調整し、その効果を評価している。

目的・背景

小売業は在庫管理、需要予測、価格戦略などにおいて売上予測が極めて重要である。従来の時系列モデルや統計的アプローチは一定の有効性を持つが、季節性やプロモーション効果などの複雑な要因を十分に反映できない場合が多い。そこで、機械学習を活用することで多次元データを扱い、予測精度を高めることが期待されている。

先行研究

従来研究では、ARIMAやSARIMAといった統計モデル、ランダムフォレストや勾配ブースティングといった機械学習モデル、さらにLSTMをはじめとするディープラーニングモデルが小売需要予測に用いられてきた。しかし、それぞれに長所短所があり、特定条件下では予測精度に課題が残る。

調査・実験・検証内容

本研究では、以下の流れで検証が行われた。

  1. データ収集と前処理

    • 小売業の実データセットを使用。販売数量、商品カテゴリー、季節要因、プロモーション情報などを特徴量として抽出。

    • 欠損値補完や正規化処理を行い、学習に適したデータを整備。

  2. モデル選定と最適化

    • 比較対象として、ランダムフォレスト、XGBoost、LightGBM、LSTMなど複数モデルを選定。

    • ハイパーパラメータ最適化にはベイズ最適化グリッドサーチを活用。

  3. 性能評価

    • 評価指標にはRMSE(平均二乗誤差平方根)、MAE(平均絶対誤差)、MAPE(平均絶対パーセント誤差)を採用。

    • 時系列クロスバリデーションにより、モデルの汎用性と安定性を検証。

  4. 結果

    • LightGBMやXGBoostが統計モデルや単純な機械学習手法よりも一貫して高い精度を示した。

    • LSTMは長期的なトレンド把握には有効だったが、短期予測では勾配ブースティング系アルゴリズムの方が優れた。

    • 特に、**特徴量エンジニアリング(季節性要因やキャンペーン情報の組み込み)**が精度向上に寄与した。

実施内容

以下では、ノートブック構成をかいつまんで紹介し、重要なコードを抜粋して説明します。
データは、UC IriveにあったOnline Retailを用いています。

1. データと前処理

  • 目的変数:日次売上(Quantity × UnitPrice を日次で集計、欠損日は0埋め)
  • クリーニング:キャンセル伝票(InvoiceNoがC始まり)除外、数量・単価の異常値除外など
import pandas as pd
import numpy as np

url = "https://archive.ics.uci.edu/ml/machine-learning-databases/00352/Online%20Retail.xlsx"
raw = pd.read_excel(url, sheet_name="Online Retail")

# 取消伝票や異常値の除外
df = raw[~raw["InvoiceNo"].astype(str).str.startswith("C")].copy()
df = df[(df["Quantity"] > 0) & (df["UnitPrice"] > 0)].copy()

# 売上金額
df["Sales"] = df["Quantity"] * df["UnitPrice"]

# 日次に集計→欠損日は0で埋める
df["date"] = pd.to_datetime(df["InvoiceDate"]).dt.date
daily = df.groupby("date", as_index=False)["Sales"].sum()
daily = daily.set_index("date").asfreq("D").fillna(0.0)
daily.index = pd.to_datetime(daily.index)

2. 特徴量設計(LightGBM用)

  • 暦特徴:曜日・月
  • ラグ:1日・7日
  • 移動平均:7日・30日
  • 祝日フラグ(必要に応じてholidaysライブラリで国別付与)
import holidays

def create_features(s):
    df = s.copy()
    df["dayofweek"] = df.index.dayofweek
    df["month"] = df.index.month
    df["lag1"] = df["Sales"].shift(1)
    df["lag7"] = df["Sales"].shift(7)
    df["roll7"] = df["Sales"].rolling(7).mean()
    df["roll30"] = df["Sales"].rolling(30).mean()
    # 例:英国祝日フラグ
    gb_holidays = holidays.UK()
    df["is_holiday"] = df.index.to_series().apply(lambda d: d in gb_holidays).astype(int)
    return df.dropna()

3. 学習・評価分割

  • シンプルに時系列の前80%を学習、後20%をテストに固定
  • 評価指標:MAE / RMSE / MAPE(ビジネスで解釈しやすい三点セット)
from sklearn.metrics import mean_absolute_error, mean_squared_error

train_ratio = 0.8
split = int(len(daily) * train_ratio)
train, test = daily.iloc[:split], daily.iloc[split:]

def metrics(y_true, y_pred):
    mae = mean_absolute_error(y_true, y_pred)
    rmse = mean_squared_error(y_true, y_pred, squared=False)
    mape = (np.abs((y_true - y_pred) / np.clip(y_true, 1e-8, None))).mean()
    return {"MAE": mae, "RMSE": rmse, "MAPE": mape}

4. LightGBM+Optunaでハイパラ最適化

  • 目的:MAPE最小化
  • 試行回数はデモ向けに20回(実務ではもっと回します)
  • 重要ポイント:リークを防ぐため、学習内のスコアは検証期間(後半)で評価
import lightgbm as lgb, optuna

train_feat = create_features(train)
test_feat  = create_features(pd.concat([train.tail(30), test]))

X_train = train_feat.drop(columns=["Sales"])
y_train = train_feat["Sales"]
X_test  = test_feat.loc[test.index].drop(columns=["Sales"])
y_test  = test.loc[X_test.index, "Sales"]

def objective(trial):
    params = {
        "objective": "regression",
        "metric": "mae",
        "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.2, log=True),
        "num_leaves": trial.suggest_int("num_leaves", 15, 255),
        "min_data_in_leaf": trial.suggest_int("min_data_in_leaf", 10, 200),
        "feature_fraction": trial.suggest_float("feature_fraction", 0.6, 1.0),
        "bagging_fraction": trial.suggest_float("bagging_fraction", 0.6, 1.0),
        "bagging_freq": trial.suggest_int("bagging_freq", 0, 7),
        "lambda_l1": trial.suggest_float("lambda_l1", 0.0, 5.0),
        "lambda_l2": trial.suggest_float("lambda_l2", 0.0, 5.0),
        "verbosity": -1,
    }
    model = lgb.train(params, lgb.Dataset(X_train, label=y_train), num_boost_round=1000)
    pred = model.predict(X_test)
    return metrics(y_test, pred)["MAPE"]

study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=20)
best_params = study.best_params

model_lgb = lgb.train({**best_params, "objective":"regression","metric":"mae","verbosity":-1},
                      lgb.Dataset(X_train, label=y_train), num_boost_round=1000)
pred_lgb = model_lgb.predict(X_test)

5. SARIMAX

ARIMAやSAMIRAXについては、比較用なので詳しくは述べませんが、季節性を含んだ時系列解析の典型的なモデルです
コードはシンプルなんですが、処理時間は結構時間がかかります

import itertools
from statsmodels.tsa.statespace.sarimax import SARIMAX

y_tr = train["Sales"].astype("float64")
y_te = test["Sales"].astype("float64")

p = d = q = range(0, 4)          # (0~3)の軽量レンジ
P = D = Q = range(0, 2)          # 季節は控えめ
s = [7]                          # 週期の季節性を仮定

def fit(order, seas):
    return SARIMAX(y_tr, order=order, seasonal_order=(*seas, s[0]),
                   enforce_stationarity=False, enforce_invertibility=False).fit(disp=False)

best_aic, best_order, best_seasonal = np.inf, None, None
for order in itertools.product(p, d, q):
    for seasonal in itertools.product(P, D, Q):
        try:
            aic = fit(order, seasonal).aic
            if np.isfinite(aic) and aic < best_aic:
                best_aic, best_order, best_seasonal = aic, order, seasonal
        except Exception:
            continue

model_arima = fit(best_order, best_seasonal)
pred_arima = model_arima.forecast(steps=len(y_te)).values

6. Prophet(週次+年次季節性)

Prophetも、時系列モデルの典型ですね。Facebook社(現Meta社)が作ってオープンソースにしているモデルです

from prophet import Prophet

prophet_df = train.reset_index().rename(columns={"date":"ds","Sales":"y"})
m = Prophet(yearly_seasonality=True, weekly_seasonality=True, daily_seasonality=False)
m.fit(prophet_df)

future = m.make_future_dataframe(periods=len(test))
fcst = m.predict(future)
pred_prophet = fcst.set_index("ds").loc[test.index, "yhat"].values

7. 精度比較(MAE / RMSE / MAPE)

  • 同一テスト期間に対して指標を横並び比較

  • どの指標も小さいほど良い。MAPEは現場目線での誤差率を直感的に共有できる

import pandas as pd

y_true = test["Sales"].values
res = pd.DataFrame([
    {"Model":"LightGBM", **metrics(y_true, pred_lgb)},
    {"Model":"ARIMA",    **metrics(y_true, pred_arima)},
    {"Model":"Prophet",  **metrics(y_true, pred_prophet)},
])
display(res)

結果は以下の通りで、どの指標もLightGBM(本論文であげられているもの)の誤差率が最小でした。

Model MAE RMSE MAPE
0 LightGBM 15036.729108 23935.151814
1 ARIMA 18140.187005 25098.092676
2 Prophet 32456.344905 42078.477329

8. 可視化(目検での妥当性チェック)

  • 現場レビューでは折れ線の重なり具合を見て、外れ日(販促・在庫切れ)の説明責任を果たすのがコツ
import matplotlib.pyplot as plt

plt.figure(figsize=(10,4))
plt.plot(test.index, y_true, label="Actual")
plt.plot(test.index, pred_lgb, label="LightGBM")
plt.plot(test.index, pred_arima, label="ARIMA")
plt.plot(test.index, pred_prophet, label="Prophet")
plt.legend(); plt.title("Model Comparison: Prediction"); plt.tight_layout(); plt.show()

LightGBMの結果の良さは、グラフにすると、より分かりやすいです。
最後の伸び(多分クリスマスセール)の予測ができていませんが、それ以外の期間は、ActualとLightGBMが重なる期間が長いです。
次点で、SARIMAXを用いたARIMAモデルですね。

サマリ

時系列モデルは、昔からある分析テーマの1つですが、それに特化したARIMAやProphetよりも、普通の回帰分析と同様にLightGBMで予測する方が精度が高いというのが面白い結果でした。

「2. 特徴量設計(LightGBM用)」のように、使っている特徴量も、時系列分析と横並びにするためにシンプルにしているので、ここにほかの情報(品揃えや、外部情報など)を付与していくと、より精度を上げることができるかもしれませんね。

Discussion