実案件/ポートフォリオ

コピペで動く需要予測|ARIMA×LightGBMでベースライン→運用まで

現場でちゃんと当たる需要予測って、どこから始めればいい?

ベースライン→検証→運用まで、一気通貫で進める“型”で解説します。

この記事は、ARIMAとLightGBMを使った需要予測ミニプロジェクトの完全ロードマップです。まず単純ベースラインを作り、時系列交差検証で勝っているか確認し、最後に運用(バッチ・配布)まで落とし込みます。すべてコピペで動かせます。

この記事で身に付く力

  • 需要予測の最短手順(ベースライン→時系列CV→本命モデル→運用)
  • ARIMA/SARIMAXとLightGBMの使い分けと実装のコピペ集
  • 本番運用に耐えるバッチ化・スケジューリングの型

全体像:ベースライン→検証→運用の“一直線”

需要予測が外れる典型は、ランダム分割での過大評価ベースライン不在運用未設計の3つ。この記事では、以下の順路で解決します。

  1. ベースライン(Naive/移動平均/季節Naive)を用意する
  2. 時系列交差検証で本当に勝っているか確認する
  3. ARIMA/SARIMAXLightGBMを使い分けて精度を伸ばす
  4. バッチ化してレポート配布まで自動化する

プロジェクト雛形(最小構成)

forecast-demo/
  data/{raw,interim,processed}
  notebooks/{01_eda,02_baseline,03_arima,04_lgbm,05_report}.ipynb
  src/{__init__.py,etl.py,baseline.py,arima.py,lgbm.py,eval.py,features.py}
  build/{report_build.py}
  requirements.txt

requirements.txt(最小)

pandas>=2.2
numpy>=2
scikit-learn>=1.5
statsmodels>=0.14
lightgbm>=4
matplotlib>=3.9

データETL(日次の店舗×SKUを想定)

from pathlib import Path
import pandas as pd

RAW = Path("data/raw/sales.csv")
OUT = Path("data/interim/sales.parquet")

def load\_sales() -> pd.DataFrame:
df = pd.read\_csv(RAW, parse\_dates=\["date"], dtype={"store":"string","sku":"string"})
df = df\[df\["qty"] >= 0].copy()
\# 粒度を合わせる(日次×店舗×SKU)
gcols = \["date","store","sku"]
df = df.groupby(gcols, as\_index=False)\["qty"].sum()
return df

if **name** == "**main**":
df = load\_sales()
OUT.parent.mkdir(parents=True, exist\_ok=True)
df.to\_parquet(OUT, compression="zstd")

ベースライン(Naive/移動平均/季節Naive)

まずは単純な予測器で基準点を敷きます。ここに勝てないモデルは採用しません(経験談)。

import pandas as pd

def naive\_last(df: pd.DataFrame, horizon: int) -> pd.DataFrame:
preds = \[]
for (store, sku), g in df.sort\_values("date").groupby(\["store","sku"], as\_index=False):
last = g.iloc\[-1]\["qty"]
future = pd.date\_range(g\["date"].max() + pd.Timedelta(days=1), periods=horizon)
preds.append(pd.DataFrame({"date"\:future, "store"\:store, "sku"\:sku, "pred"\:last}))
return pd.concat(preds, ignore\_index=True)

def moving\_avg(df: pd.DataFrame, horizon: int, window: int = 7) -> pd.DataFrame:
preds = \[]
for (store, sku), g in df.sort\_values("date").groupby(\["store","sku" ], as\_index=False):
ma = g\["qty"].tail(window).mean()
future = pd.date\_range(g\["date"].max() + pd.Timedelta(days=1), periods=horizon)
preds.append(pd.DataFrame({"date"\:future, "store"\:store, "sku"\:sku, "pred"\:ma}))
return pd.concat(preds, ignore\_index=True)

def seasonal\_naive(df: pd.DataFrame, horizon: int, season: int = 7) -> pd.DataFrame:
preds = \[]
for (store, sku), g in df.sort\_values("date").groupby(\["store","sku" ], as\_index=False):
last\_week = g\["qty"].tail(season).to\_list()
future = pd.date\_range(g\["date"].max() + pd.Timedelta(days=1), periods=horizon)
yhat = (last\_week \* ((horizon + season - 1)//season))\[:horizon]
preds.append(pd.DataFrame({"date"\:future, "store"\:store, "sku"\:sku, "pred"\:yhat}))
return pd.concat(preds, ignore\_index=True)

評価指標はMAE/MAPE/SMAPEを用意。業務では在庫差の期待値に直結するMAEや、0除算に強いSMAPEをよく使います。[内部リンク:モデル評価]

時系列交差検証(Expanding Window)

未来情報の混入(リーク)を避けるため、過去で学習→未来で評価を繰り返します。

import pandas as pd
from sklearn.metrics import mean_absolute_error

def expanding\_cv\_scores(df: pd.DataFrame, horizon=7, min\_train=90, step=7, method="seasonal"):
df = df.sort\_values("date").copy()
dates = df\["date"].unique()
scores = \[]
for cut in range(min\_train, len(dates) - horizon, step):
cutoff = dates\[cut]
train = df\[df\["date"] <= cutoff]
test\_end = cutoff + pd.Timedelta(days=horizon)
test = df\[(df\["date"] > cutoff) & (df\["date"] <= test\_end)]
if method == "seasonal":
from .baseline import seasonal\_naive as forecaster
elif method == "ma":
from .baseline import moving\_avg as forecaster
else:
from .baseline import naive\_last as forecaster
pred = forecaster(train, horizon=horizon)
m = train\[\["store","sku"]].drop\_duplicates().assign(key=1).merge(
pd.DataFrame({"date"\:pd.date\_range(cutoff+pd.Timedelta(days=1), periods=horizon)}).assign(key=1), on="key"
).drop(columns="key")
pred = m.merge(pred, on=\["date","store","sku"], how="left")
joined = test.merge(pred, on=\["date","store","sku"], how="left")
mae = mean\_absolute\_error(joined\["qty"], joined\["pred"])
scores.append({"cutoff"\:cutoff, "mae"\:mae})
return pd.DataFrame(scores)

ポイントはただ1つ。未来を混ぜない。[内部リンク:モデル評価]

ARIMA/SARIMAX(季節性+外生変数)

単一系列が長く、季節性がはっきりしているときの第一候補。プロモや天気などの外生変数を入れられるのが強み。

import warnings
warnings.filterwarnings("ignore")
import pandas as pd
from statsmodels.tsa.statespace.sarimax import SARIMAX

def fit\_sarimax(ts: pd.Series, order=(1,1,1), seasonal\_order=(1,1,1,7), exog: pd.DataFrame | None = None):
model = SARIMAX(ts, order=order, seasonal\_order=seasonal\_order, exog=exog)
res = model.fit(disp=False)
return res

def forecast\_sarimax(res, steps: int, exog\_future: pd.DataFrame | None = None) -> pd.Series:
fc = res.get\_forecast(steps=steps, exog=exog\_future)
return fc.predicted\_mean
# ts: DatetimeIndex付きSeries(qty)
res = fit_sarimax(ts, order=(1,1,1), seasonal_order=(1,1,1,7))
yhat = forecast_sarimax(res, steps=14)

まずは週季節(7)から。次に必要なら年季節(365)、外生変数を検討。

LightGBM(特徴量×ツリーモデル)

多系列(店舗×SKU)や非線形効果が強いときはこちらが有利。ラグ・移動統計・カレンダーで攻めます。

import pandas as pd

def add\_time\_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.sort\_values(\["store","sku","date"]).copy()
g = df.groupby(\["store","sku"], group\_keys=False)
for l in (1,7,14):
df\[f"lag{l}"] = g\["qty"].shift(l)
for w in (7,28):
df\[f"ma{w}"] = g\["qty"].shift(1).rolling(w, min\_periods=3).mean()
df\[f"std{w}"] = g\["qty"].shift(1).rolling(w, min\_periods=3).std()
df\["dow"] = df\["date"].dt.dayofweek.astype("int8")
return df.dropna()
import pandas as pd
from lightgbm import LGBMRegressor
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_absolute_error
from .features import add_time_features

def fit\_lgbm(df: pd.DataFrame, horizon=7, features: list\[str] | None=None):
df = add\_time\_features(df)
y = df\["qty"]
X = df\[features] if features else df.drop(columns=\["qty","date","store","sku"])
tscv = TimeSeriesSplit(n\_splits=5)
maes = \[]
model = None
for tr, va in tscv.split(X):
m = LGBMRegressor(n\_estimators=800, learning\_rate=0.05, subsample=0.8, colsample\_bytree=0.8)
m.fit(X.iloc\[tr], y.iloc\[tr], eval\_set=\[(X.iloc\[va], y.iloc\[va])], eval\_metric="l1", verbose=False)
p = m.predict(X.iloc\[va])
maes.append(mean\_absolute\_error(y.iloc\[va], p))
model = m
return model, sum(maes)/len(maes)

def make\_future\_df(df\_hist: pd.DataFrame, horizon: int) -> pd.DataFrame:
out = \[]
for (store, sku), g in df\_hist.groupby(\["store","sku"]):
last\_date = g\["date"].max()
future = pd.DataFrame({
"date": pd.date\_range(last\_date + pd.Timedelta(days=1), periods=horizon),
"store": store, "sku": sku
})
out.append(pd.concat(\[g, future], ignore\_index=True))
fut = pd.concat(out, ignore\_index=True)
fut = add\_time\_features(fut)
return fut\[fut\["date"] > df\_hist\["date"].max()]
model, cv_mae = fit_lgbm(hist_df)
future_df = make_future_df(hist_df, horizon=14)
Xf = future_df.drop(columns=["qty","date","store","sku"])  # qtyはNaN
future_df["pred"] = model.predict(Xf)
preds = future_df[["date","store","sku","pred"]]

リーク防止(shiftやローリングの取り扱い)と、系列ごとの分布差への対処(対数変換やIDエンコーディング)を忘れずに。

ARIMA vs LightGBM の使い分け

  • ARIMA/SARIMAX:単一系列が長い/季節性が強い/外生が少ない
  • LightGBM:多系列(店舗×SKU)/外生が多い/非線形が効く
  • 実務では両方CVで比較し、単純ベースラインにも勝つ方を採用。平均などのアンサンブルも有効。

レポート・可視化の型

  • 予測 vs 実績:折れ線2本+(ARIMAなら)予測区間
  • 誤差の箱ひげ:店別/SKU別
  • 特徴量重要度(Gain):LightGBM
  • 意思決定図:発注点=需要予測×LT−安全在庫(サンプル計算) → [内部リンク:データレポート納品テンプレ]

推論バッチとスケジュール

from pathlib import Path
import pandas as pd
from .etl import load_sales
from .lgbm import fit_lgbm, make_future_df

OUT = Path("data/processed/preds.csv")

if **name** == "**main**":
hist = load\_sales()
model, score = fit\_lgbm(hist)
fut = make\_future\_df(hist, horizon=7)
Xf = fut.drop(columns=\["qty","date","store","sku"])
fut\["pred"] = model.predict(Xf)
OUT.parent.mkdir(parents=True, exist\_ok=True)
fut\[\["date","store","sku","pred"]].to\_csv(OUT, index=False)
  • スケジュール例:毎朝5:30学習→6:00レポート生成→6:10配布 → [内部リンク:自動化:スケジューリングと業務改善の型]
  • 環境固定:[内部リンク:Docker超入門]で箱ごと固定して再現性を担保

よくある罠と対処

症状原因対処
CVでは勝つが本番で悪化分布変化/季節イベント最近データ重視、カレンダー/価格/天気など外生追加
MAPEが無限大0に近い実績で割ったSMAPE/MAEに切替、epsilon-MAPE
学習が不安定ラグ/ローリングのNaN十分な初期期間の除外、min_periods設定
予測が負になるノイズ/線形下限0でクリップ or 対数空間で回帰
学習に時間がかかる多系列×長期系列サンプリング、特徴量削減、LightGBMのearly_stopping

ユースケース別の第一手

  • 小売(店舗×SKU):季節Naive→SARIMAXで基準→LightGBMで多系列をまとめて改善
  • EC(アクセス→売上):プロモ/価格を外生、休日/天気を特徴量に
  • B2B受注:営業日ベースに整形、長期トレンドと短期変動を分けて扱う

今日やること(90分)

  1. forecast-demo/を作り、ETL→ベースライン→時系列CVを通す
  2. SARIMAXで週季節(7)のモデルを1本、ベースラインに勝つか検証
  3. LightGBMでラグ/移動平均のみの特徴量でCVを回す
  4. batch.pyで7日先のCSVを出力し、[内部リンク:データレポート納品テンプレ]の体裁でPDFを1枚

まとめ

需要予測は「正しい検証と運用設計」が9割。季節Naiveに勝つ→時系列CV→ARIMAとLightGBMを使い分けの順で、静かに回る予測を作りましょう。

次は、以下の関連記事をどうぞ。

ポートフォリオ
実案件型ポートフォリオ:要件→実装→レポートの型|“業務再現”で採用担当に刺さる作り方

結論:採用担当が知りたいのは 「Kaggleのスコア」ではなく「現場で本当に回るか」。だからこそ、要件が言語化され、再現できる実装があり、最後は意思決定に直結するレポートで締める——この3点を1つの物 ...

レポート納品
コピペで回るレポート納品|Jupyter→PDF/HTML→共有の自動化テンプレ

毎週のレポート納品、朝にバタつきませんか? コードや図表は作ったのに、PDF化や共有で崩れる…。その“揺らぎ”を今日で終わらせましょう。 分析の価値は、最後の“納品物”で決まります。本記事では、Jup ...

自動化
“落ちない”社内自動化3選:再実行安全・ロック・JSONログで回す設計とテンプレ

社内の自動化、まず何から作ればいい? ちゃんと動き続けて、運用が楽になる設計が知りたい…! そんな悩みに対して、現場で短期に価値を出しやすい“3つの自動化”と、そのまま使える設計&コードの型をまとめま ...

モデル評価
【保存版】モデル評価:指標の選び方・交差検証・閾値最適化・ビジネス接続を“実務の型”で解説

精度が上がらない原因の多くは「評価設計の誤り」にあります。評価とは「何点取れたか」ではなく、意思決定に耐えるかを測る営み。この記事では、回帰/分類/ランキングの指標の選び方、交差検証の正しい使い分け、 ...

ハイパーパラメータ入門
【保存版】ハイパーパラメータ入門:Grid/Random/Optunaの実務チューニング完全ガイド

チューニングのゴールは「スコアの数字遊び」ではありません。意思決定に耐える安定した最適化を短時間で作ること。本記事は未経験〜初学者が週10時間×2週間で、GridSearchCV / Randomiz ...

最近のコメント

    • この記事を書いた人
    • 最新記事

    ふみと

    このブログでは、データサイエンティストとして市場価値を上げる方法を独自にまとめて発信しています。

    【プロフィール】
    ・大手企業データサイエンティスト/マーケティングサイエンティスト(10年、年収900万円台)/案件100件以上
    ・資格:JDLA E資格(日本ディープラーニング協会主催)/JDLA Community(CDLE会員)/Advanced Marketer/ビジネス統計スペシャリスト/統計検定2級/TOEIC 805
    ・スキル:Python/Tableau/SQL/機械学習/Deep Learning/RPA

    -実案件/ポートフォリオ