
現場でちゃんと当たる需要予測って、どこから始めればいい?
ベースライン→検証→運用まで、一気通貫で進める“型”で解説します。
この記事は、ARIMAとLightGBMを使った需要予測ミニプロジェクトの完全ロードマップです。まず単純ベースラインを作り、時系列交差検証で勝っているか確認し、最後に運用(バッチ・配布)まで落とし込みます。すべてコピペで動かせます。
この記事で身に付く力
- 需要予測の最短手順(ベースライン→時系列CV→本命モデル→運用)
- ARIMA/SARIMAXとLightGBMの使い分けと実装のコピペ集
- 本番運用に耐えるバッチ化・スケジューリングの型
全体像:ベースライン→検証→運用の“一直線”
需要予測が外れる典型は、ランダム分割での過大評価、ベースライン不在、運用未設計の3つ。この記事では、以下の順路で解決します。
- ベースライン(Naive/移動平均/季節Naive)を用意する
- 時系列交差検証で本当に勝っているか確認する
- ARIMA/SARIMAXとLightGBMを使い分けて精度を伸ばす
- バッチ化してレポート配布まで自動化する
プロジェクト雛形(最小構成)
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分)
forecast-demo/
を作り、ETL→ベースライン→時系列CVを通す- SARIMAXで週季節(7)のモデルを1本、ベースラインに勝つか検証
- LightGBMでラグ/移動平均のみの特徴量でCVを回す
- 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 ...
最近のコメント