100本ノック / 機械学習 / 実務機械学習100本ノック

製造業の需要予測をPythonで実践|時系列分析から在庫・発注計画まで10本ノック

製造業の需要予測を発注判断につなぐ:時系列分析10本ノック(No.061〜No.070)

日々の受注・出荷実績を、単なる折れ線グラフで終わらせず、生産・在庫・発注の意思決定へ接続する実践Notebookです。架空の産業用ポンプ部品「PX-100」の日別需要を使い、集計、トレンド・季節性把握、予測モデル、時系列検証、誤差分析、在庫シミュレーションまでを一気通貫で扱います。

[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。
掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。

はじめに:この記事で扱う製造業の実務課題

製造業では、需要の見通しが原材料発注、要員配置、生産順序、完成品在庫に連鎖します。過大予測は滞留在庫と資金拘束を、過小予測は欠品、特急輸送、納期遅延を招きます。本記事の目的は「高精度なモデルを作ること」だけではなく、予測の不確実性を含めて、担当者が翌週の発注量を説明可能な形で決められる状態を作ることです。

現場でよくある状況

  • 月次合計だけを見ており、曜日差や休業日の影響を把握できていない
  • ランダム分割で高い精度が出たものの、本番運用では再現しない
  • 平均誤差は小さいが、販促・連休前後など重要な期間だけ大きく外れる
  • 予測値は作ったものの、安全在庫や発注点へ変換するルールがない

なぜこの問題は判断が難しいのか

時系列にはトレンド、周期性、突発要因が重なります。また、将来を予測する時点では知り得ない情報を特徴量へ混ぜると、データリークにより見かけの精度だけが高くなります。さらに、同じ予測誤差でも欠品と余剰の事業損失は非対称です。したがって、時間順序を守った検証と、業務コストによる評価が不可欠です。

今回扱うノックの全体像

No.テーマ意思決定への接続
061日別売上の可視化変動・異常期間の把握
062週別・月別集計能力・購買計画の粒度選択
063移動平均基調と短期ノイズの分離
064曜日別・月別季節性定期的な山谷の事前織り込み
065ラグ特徴量直近実績を使った需要予測
066回帰モデル複数要因を統合した予測
067時系列検証本番に近い性能評価
068予測誤差の可視化偏り・ばらつきの把握
069外れる期間の分析改善対象と例外運用の特定
070在庫・発注計画への接続サービス水準と在庫費用の両立

Python環境の準備

pandasnumpy でデータを扱い、matplotlib で可視化し、scikit-learn でモデル構築と評価を行います。乱数シードは 42 に固定します。グラフ内は実行環境による文字化けを避けるため英語表記とし、読み取りは本文で詳しく説明します。

import warnings
warnings.filterwarnings("ignore", category=FutureWarning)

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import TimeSeriesSplit

SEED = 42
rng = np.random.default_rng(SEED)
pd.set_option("display.max_columns", 20)
plt.rcParams["figure.figsize"] = (11, 4)
print("Libraries loaded. Random seed:", SEED)
Libraries loaded. Random seed: 42

架空データの作成

2024年1月から2年間の日別データを生成します。目的変数は顧客要求量に相当する demand_units、金額は sales_yen です。需要には緩やかな成長、平日効果、月別季節性、販促効果、年末前の駆け込み、夏季休業、2025年4月の一時的な供給制約を設定します。

概念的には、日次需要 yty_t を次の加法モデルとして作ります。

yt=Tt+Wt+St+18Pt+Et+εt,εtN(0,52)y_t = T_t + W_t + S_t + 18P_t + E_t + \varepsilon_t, \qquad \varepsilon_t \sim \mathcal{N}(0, 5^2)

TtT_t はトレンド、WtW_t は曜日効果、StS_t は月効果、PtP_t は販促フラグ、EtE_t は臨時要因です。実務では、受注残や失注を含まない出荷実績をそのまま「需要」と見なすと、供給制約期間の潜在需要を過小評価する点に注意が必要です。

dates = pd.date_range("2024-01-01", periods=730, freq="D")
n = len(dates)
weekday_effect = np.array([8, 10, 9, 7, 4, -16, -20])
month_effect = {1: -5, 2: -2, 3: 8, 4: 3, 5: -1, 6: 5,
                7: 7, 8: -10, 9: 6, 10: 4, 11: 9, 12: 13}

df = pd.DataFrame({"date": dates})
df["weekday"] = df["date"].dt.dayofweek
df["month"] = df["date"].dt.month
df["promotion"] = ((df["date"].dt.day >= 10) & (df["date"].dt.day <= 12) &
                   (df["month"].isin([3, 6, 9, 11]))).astype(int)
trend = np.linspace(0, 16, n)
season = df["month"].map(month_effect).to_numpy()
special = np.zeros(n)
special[(df["date"].dt.month == 8) & (df["date"].dt.day.between(11, 16))] -= 30
special[(df["date"].dt.month == 12) & (df["date"].dt.day.between(16, 22))] += 22
supply_constraint = df["date"].between("2025-04-07", "2025-04-18")
special[supply_constraint] -= 24

latent = (72 + trend + weekday_effect[df["weekday"].to_numpy()] + season
          + 18 * df["promotion"].to_numpy() + special + rng.normal(0, 5, n))
df["demand_units"] = np.maximum(0, np.rint(latent)).astype(int)
df["unit_price_yen"] = 12500
df["sales_yen"] = df["demand_units"] * df["unit_price_yen"]
df["special_period"] = np.select(
    [supply_constraint,
     (df["date"].dt.month == 8) & (df["date"].dt.day.between(11, 16)),
     (df["date"].dt.month == 12) & (df["date"].dt.day.between(16, 22))],
    ["supply_constraint", "summer_shutdown", "year_end_rush"], default="normal")

print(f"Period: {df['date'].min().date()} to {df['date'].max().date()} / {len(df):,} days")
display(df.head())
display(df[["demand_units", "sales_yen"]].describe().round(1))
Period: 2024-01-01 to 2025-12-30 / 730 days
date weekday month promotion demand_units unit_price_yen sales_yen special_period
0 2024-01-01 0 1 0 77 12500 962500 normal
1 2024-01-02 1 1 0 72 12500 900000 normal
2 2024-01-03 2 1 0 80 12500 1000000 normal
3 2024-01-04 3 1 0 79 12500 987500 normal
4 2024-01-05 4 1 0 61 12500 762500 normal
demand_units sales_yen
count 730.0 730.0
mean 83.3 1041061.6
std 17.7 221419.6
min 11.0 137500.0
25% 72.0 900000.0
50% 84.0 1050000.0
75% 95.0 1187500.0
max 136.0 1700000.0

No.061:日別売上を可視化する

実務での意味

日別推移は、集計値では消える短期変動、操業カレンダー、突発的な山谷を確認する出発点です。製造現場では、売上金額だけでなく数量も併記し、価格改定と物量変化を混同しないことが重要です。

分析・モデル化の考え方

まず加工を最小限にして原系列を描きます。欠損、ゼロ、段差、分散の変化を観察し、「モデルが学習すべき規則」と「個別に管理すべき例外」を仮説化します。

Pythonで確認する

fig, ax = plt.subplots()
ax.plot(df["date"], df["demand_units"], color="steelblue", linewidth=0.9)
ax.set_title("Daily Demand for PX-100")
ax.set_xlabel("Date")
ax.set_ylabel("Demand (units/day)")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("Peak day:")
display(df.nlargest(3, "demand_units")[["date", "demand_units", "promotion", "special_period"]])

png

Peak day:
date demand_units promotion special_period
721 2025-12-22 136 0 year_end_rush
717 2025-12-18 133 0 year_end_rush
351 2024-12-17 129 0 year_end_rush

結果の読み取り

週末の規則的な落ち込みに加え、販促や年末前に山が、夏季休業や供給制約期に谷が見えます。したがって、単純な全期間平均だけで日次生産量を決めるのは不適切です。ピーク日の背景情報を表で照合することで、再現するイベントか一過性かを切り分けられます。

No.062:週別・月別売上を集計する

実務での意味

日次は現場配員、週次は部材発注、月次はS&OPや予算というように、意思決定ごとに必要な粒度が異なります。合計数量と平均日販を使い分けると、月の日数差による誤読を防げます。

分析・モデル化の考え方

期間合計は必要能力を、期間平均は需要強度を表します。週次は月曜始まりで集計し、月次では合計と日平均の両方を計算します。

Pythonで確認する

ts = df.set_index("date")
weekly = ts["demand_units"].resample("W-SUN").sum().rename("weekly_units")
monthly = ts["demand_units"].resample("MS").agg(["sum", "mean"])
monthly.columns = ["monthly_units", "avg_daily_units"]
display(monthly.tail(8).round(1))

fig, axes = plt.subplots(2, 1, figsize=(11, 7))
axes[0].plot(weekly.index, weekly, color="darkorange")
axes[0].set_title("Weekly Demand")
axes[0].set_xlabel("Week ending")
axes[0].set_ylabel("Demand (units/week)")
axes[0].grid(True, alpha=0.3)
axes[1].bar(monthly.index, monthly["monthly_units"], width=20, color="slateblue")
axes[1].set_title("Monthly Demand")
axes[1].set_xlabel("Month")
axes[1].set_ylabel("Demand (units/month)")
axes[1].grid(True, axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
monthly_units avg_daily_units
date
2025-05-01 2519 81.3
2025-06-01 2702 90.1
2025-07-01 2864 92.4
2025-08-01 2091 67.5
2025-09-01 2810 93.7
2025-10-01 2802 90.4
2025-11-01 2901 96.7
2025-12-01 3161 105.4

png

結果の読み取り

週次には操業曜日とイベントの影響が残る一方、月次では緩やかな成長と月ごとの濃淡が読みやすくなります。購買契約を月単位で結ぶ場合も、月合計だけでなく週次ピークを確認し、納入頻度と保管能力を検討する必要があります。

No.063:移動平均でトレンドを見る

実務での意味

日々のノイズに反応して生産計画を頻繁に変えると、段取り替えや残業が増えます。移動平均は、短期変動をならして基調を共有する簡便な管理指標です。

分析・モデル化の考え方

kk 日移動平均は次式です。

MAt(k)=1ki=0k1ytiMA_t^{(k)}=\frac{1}{k}\sum_{i=0}^{k-1}y_{t-i}

7日平均は曜日周期をならし、28日平均は中期基調を表します。ただし移動平均は変化に遅れて反応するため、予測値そのものとは区別します。

Pythonで確認する

df["ma_7"] = df["demand_units"].rolling(7).mean()
df["ma_28"] = df["demand_units"].rolling(28).mean()
view = df.tail(240)
fig, ax = plt.subplots()
ax.plot(view["date"], view["demand_units"], color="lightgray", label="Daily", linewidth=0.8)
ax.plot(view["date"], view["ma_7"], label="7-day MA", linewidth=1.5)
ax.plot(view["date"], view["ma_28"], label="28-day MA", linewidth=2)
ax.set_title("Demand Trend with Moving Averages")
ax.set_xlabel("Date")
ax.set_ylabel("Demand (units/day)")
ax.grid(True, alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
print("Latest moving averages:")
latest_ma = df[["date", "demand_units", "ma_7", "ma_28"]].tail(5).copy()
latest_ma[["ma_7", "ma_28"]] = latest_ma[["ma_7", "ma_28"]].round(1)
display(latest_ma)

png

Latest moving averages:
date demand_units ma_7 ma_28
725 2025-12-26 112 108.0 104.8
726 2025-12-27 86 105.7 105.1
727 2025-12-28 85 103.3 105.5
728 2025-12-29 103 98.6 105.1
729 2025-12-30 105 99.4 104.9

結果の読み取り

7日平均は直近の需給変化を捉え、28日平均は基調を安定して示します。両者の乖離が拡大したときは、需要水準の変化か一時イベントかを確認する合図になります。窓幅は調達リードタイムや計画見直し周期に合わせて設定します。

No.064:曜日別・月別の季節性を見る

実務での意味

曜日差は日々の配員・出荷便、月差は定修・予算消化・季節需要に関係します。繰り返すパターンを事前に織り込めば、毎回「異常」として対応する無駄を減らせます。

分析・モデル化の考え方

曜日・月ごとの平均値を全体平均で割り、季節指数として確認します。指数が1.10なら、平均より約10%高い需要水準です。年ごとの構成変化もあるため、複数年で安定しているかを併せて確認します。

Pythonで確認する

weekday_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
overall_mean = df["demand_units"].mean()
weekday_profile = df.groupby("weekday")["demand_units"].mean().reindex(range(7))
weekday_profile.index = weekday_names
month_profile = df.groupby("month")["demand_units"].mean().reindex(range(1, 13))
seasonality = pd.DataFrame({
    "weekday_avg": weekday_profile,
    "weekday_index": weekday_profile / overall_mean
})
display(seasonality.round(2))

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].bar(weekday_profile.index, weekday_profile.values, color="teal")
axes[0].set_title("Average Demand by Weekday")
axes[0].set_xlabel("Weekday")
axes[0].set_ylabel("Average demand (units/day)")
axes[0].grid(True, axis="y", alpha=0.3)
axes[1].plot(month_profile.index, month_profile.values, marker="o", color="crimson")
axes[1].set_title("Average Demand by Month")
axes[1].set_xlabel("Month")
axes[1].set_ylabel("Average demand (units/day)")
axes[1].set_xticks(range(1, 13))
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
weekday_avg weekday_index
Mon 91.85 1.10
Tue 93.90 1.13
Wed 91.88 1.10
Thu 89.88 1.08
Fri 85.79 1.03
Sat 66.80 0.80
Sun 62.72 0.75

png

結果の読み取り

平日、とくに週前半が高く、週末が低い構造が明確です。月別では年末前などの山と夏季の谷が確認できます。これは能力計画を全日同一にせず、定期パターンに合わせて配分する根拠になります。ただし平均値は販促構成にも左右されるため、因果効果とは断定しません。

No.065:ラグ特徴量で需要予測する

実務での意味

直近実績や前年同時期は、担当者が経験的に参照する重要情報です。ラグ特徴量により、その判断を再現可能な計算へ落とし込めます。

分析・モデル化の考え方

LL 日ラグは xt(L)=ytLx_t^{(L)}=y_{t-L} です。ここでは前日、1週前、2週前、4週前を使います。shift によって当日実績を説明変数へ混入させないことが、データリーク防止の要点です。まず1週前実績をそのまま予測とする季節ナイーブ法を基準にします。

Pythonで確認する

for lag in [1, 7, 14, 28]:
    df[f"lag_{lag}"] = df["demand_units"].shift(lag)
df["rolling_7_past"] = df["demand_units"].shift(1).rolling(7).mean()

lag_data = df.dropna(subset=["lag_1", "lag_7", "lag_14", "lag_28", "rolling_7_past"]).copy()
cutoff = pd.Timestamp("2025-09-01")
lag_test = lag_data[lag_data["date"] >= cutoff].copy()
lag_test["naive_pred"] = lag_test["lag_7"]
naive_mae = mean_absolute_error(lag_test["demand_units"], lag_test["naive_pred"])
naive_rmse = mean_squared_error(lag_test["demand_units"], lag_test["naive_pred"]) ** 0.5
print(f"Seasonal naive (lag 7) MAE: {naive_mae:.2f} units")
print(f"Seasonal naive (lag 7) RMSE: {naive_rmse:.2f} units")
display(lag_test[["date", "demand_units", "lag_1", "lag_7", "rolling_7_past", "naive_pred"]].head())
Seasonal naive (lag 7) MAE: 9.92 units
Seasonal naive (lag 7) RMSE: 12.72 units
date demand_units lag_1 lag_7 rolling_7_past naive_pred
609 2025-09-01 102 47.0 90.0 75.714286 90.0
610 2025-09-02 96 102.0 82.0 77.428571 82.0
611 2025-09-03 102 96.0 81.0 79.428571 81.0
612 2025-09-04 91 102.0 85.0 82.428571 85.0
613 2025-09-05 98 91.0 83.0 83.285714 83.0

結果の読み取り

1週前実績だけでも曜日パターンを反映できるため、有力な基準モデルになります。高度なモデルは、この基準より実務上意味のある幅で改善して初めて採用候補です。RMSEがMAEより大きい場合は、一部の大外れが評価を押し上げています。

No.066:回帰モデルで時系列予測する

実務での意味

需要は直近実績だけでなく、曜日、月、販促、トレンドなど複数要因で決まります。回帰モデルはこれらを統合し、翌日需要を定量化します。

分析・モデル化の考え方

線形回帰を説明しやすい基準、ランダムフォレストを非線形な関係を扱う候補として比較します。学習は過去、テストは未来に固定し、評価指標には誤差の平均的な大きさを表すMAEと、大外れを強く罰するRMSEを使います。販促予定は予測時点で既知という前提です。

Pythonで確認する

feature_cols = ["lag_1", "lag_7", "lag_14", "lag_28", "rolling_7_past",
                "weekday", "month", "promotion"]
model_data = lag_data.copy()
train = model_data[model_data["date"] < cutoff]
test = model_data[model_data["date"] >= cutoff].copy()
X_train, y_train = train[feature_cols], train["demand_units"]
X_test, y_test = test[feature_cols], test["demand_units"]

models = {
    "LinearRegression": LinearRegression(),
    "RandomForest": RandomForestRegressor(
        n_estimators=250, max_depth=8, min_samples_leaf=4,
        random_state=SEED, n_jobs=-1
    ),
}
rows = []
for name, model in models.items():
    model.fit(X_train, y_train)
    pred = model.predict(X_test)
    rows.append({"model": name,
                 "MAE": mean_absolute_error(y_test, pred),
                 "RMSE": mean_squared_error(y_test, pred) ** 0.5})
    test[f"pred_{name}"] = pred
comparison = pd.DataFrame(rows).set_index("model")
comparison.loc["SeasonalNaive"] = [naive_mae, naive_rmse]
display(comparison.sort_values("MAE").round(2))

best_name = comparison[comparison.index != "SeasonalNaive"]["MAE"].idxmin()
best_model = models[best_name]
test["prediction"] = test[f"pred_{best_name}"]
print("Selected regression model:", best_name)
MAE RMSE
model
RandomForest 6.17 8.29
LinearRegression 6.96 8.78
SeasonalNaive 9.92 12.72
Selected regression model: RandomForest

結果の読み取り

モデル比較では、必ず季節ナイーブ法との改善幅を確認します。複雑なモデルがわずかに良いだけなら、説明性、保守性、再学習工数を含めて線形モデルを選ぶ余地があります。本Notebookでは回帰モデルのうちテストMAEが小さい方を後続分析に使います。

No.067:時系列データの検証方法を学ぶ

実務での意味

一度のテスト期間だけでは、特定季節に偶然合った可能性があります。複数の過去時点で「その時点までのデータだけを学習し、その後を予測する」検証により、運用時の性能変動を見積もります。

分析・モデル化の考え方

TimeSeriesSplit は学習期間を徐々に拡張するウォークフォワード検証です。ランダム分割と違い、未来から過去への情報混入を防ぎます。各foldの平均だけでなく、最悪foldも能力・安全在庫設計に利用します。

Pythonで確認する

tscv = TimeSeriesSplit(n_splits=5, test_size=60)
cv_rows = []
X_all, y_all = model_data[feature_cols], model_data["demand_units"]
for fold, (tr_idx, va_idx) in enumerate(tscv.split(X_all), start=1):
    fold_model = RandomForestRegressor(
        n_estimators=180, max_depth=8, min_samples_leaf=4,
        random_state=SEED, n_jobs=-1
    )
    fold_model.fit(X_all.iloc[tr_idx], y_all.iloc[tr_idx])
    fold_pred = fold_model.predict(X_all.iloc[va_idx])
    cv_rows.append({
        "fold": fold,
        "train_end": model_data.iloc[tr_idx[-1]]["date"].date(),
        "valid_start": model_data.iloc[va_idx[0]]["date"].date(),
        "valid_end": model_data.iloc[va_idx[-1]]["date"].date(),
        "MAE": mean_absolute_error(y_all.iloc[va_idx], fold_pred),
        "RMSE": mean_squared_error(y_all.iloc[va_idx], fold_pred) ** 0.5,
    })
cv_results = pd.DataFrame(cv_rows)
display(cv_results.round(2))
print(f"CV MAE mean: {cv_results['MAE'].mean():.2f}, worst: {cv_results['MAE'].max():.2f}")
fold train_end valid_start valid_end MAE RMSE
0 1 2025-03-05 2025-03-06 2025-05-04 8.30 11.45
1 2 2025-05-04 2025-05-05 2025-07-03 5.12 6.56
2 3 2025-07-03 2025-07-04 2025-09-01 7.18 9.54
3 4 2025-09-01 2025-09-02 2025-10-31 5.09 6.50
4 5 2025-10-31 2025-11-01 2025-12-30 6.99 9.63
CV MAE mean: 6.53, worst: 8.30

結果の読み取り

fold間の誤差差は、モデル性能が季節やイベント構成に依存することを示します。平均MAEだけで安定運用を約束せず、最悪値とばらつきを関係者へ共有します。データ構造が変わる場合は、固定長の学習窓も比較対象になります。

No.068:予測誤差を可視化する

実務での意味

総合スコアだけでは、過大予測と過小予測の偏りや、大外れの時期が見えません。誤差を可視化すると、発注量を恒常的に増減すべきか、例外管理が必要かを判断できます。

分析・モデル化の考え方

残差を et=yty^te_t=y_t-\hat{y}_t と定義します。正なら需要を過小予測し、欠品側のリスクがあります。時系列プロットで期間依存性、ヒストグラムで偏りと裾の長さを確認します。

Pythonで確認する

test["residual"] = test["demand_units"] - test["prediction"]
test["abs_error"] = test["residual"].abs()
fig, axes = plt.subplots(2, 1, figsize=(11, 7))
axes[0].plot(test["date"], test["demand_units"], label="Actual", color="black")
axes[0].plot(test["date"], test["prediction"], label="Predicted", color="royalblue")
axes[0].set_title("Actual vs Predicted Demand")
axes[0].set_xlabel("Date")
axes[0].set_ylabel("Demand (units/day)")
axes[0].grid(True, alpha=0.3)
axes[0].legend()
axes[1].bar(test["date"], test["residual"], color=np.where(test["residual"] >= 0, "tomato", "steelblue"))
axes[1].axhline(0, color="black", linewidth=0.8)
axes[1].set_title("Forecast Residuals (Actual - Predicted)")
axes[1].set_xlabel("Date")
axes[1].set_ylabel("Residual (units)")
axes[1].grid(True, axis="y", alpha=0.3)
plt.tight_layout()
plt.show()

display(test["residual"].describe(percentiles=[0.1, 0.5, 0.9]).to_frame().round(2))

png

residual
count 121.00
mean 3.90
std 7.34
min -16.97
10% -4.25
50% 3.03
90% 13.00
max 34.19

結果の読み取り

実測と予測は通常期間の周期を概ね追いますが、イベント前後には残差が大きくなります。残差平均がゼロ付近でも、正側の裾が長ければ欠品リスクは残ります。安全在庫には平均誤差ではなく、リードタイム中誤差の分布を反映します。

No.069:予測が外れる期間を分析する

実務での意味

大外れ日の原因が販促、休業、供給制約、マスタ誤りのどれかで、打ち手は異なります。上位誤差を業務イベントと結び付けることで、特徴量追加、ルール補正、データ修正、担当者承認へ振り分けられます。

分析・モデル化の考え方

絶対誤差の上位10%を「要レビュー日」とし、期間区分別にMAEと件数を集計します。閾値は統計的な便宜ではなく、許容欠品数や特急費などの業務基準で最終決定します。

Pythonで確認する

review_threshold = test["abs_error"].quantile(0.90)
test["review_flag"] = test["abs_error"] >= review_threshold
worst_days = test.nlargest(10, "abs_error")[[
    "date", "demand_units", "prediction", "residual", "abs_error",
    "promotion", "special_period"
]]
worst_days_display = worst_days.copy()
worst_days_display[["prediction", "residual", "abs_error"]] = (
    worst_days_display[["prediction", "residual", "abs_error"]].round(1)
)
display(worst_days_display)

period_error = (test.groupby("special_period")
                .agg(days=("date", "size"), MAE=("abs_error", "mean"),
                     review_days=("review_flag", "sum"))
                .sort_values("MAE", ascending=False))
display(period_error.round(2))
print(f"Review threshold (90th percentile): {review_threshold:.2f} units")
date demand_units prediction residual abs_error promotion special_period
721 2025-12-22 136 101.8 34.2 34.2 0 year_end_rush
715 2025-12-16 127 106.3 20.7 20.7 0 year_end_rush
720 2025-12-21 102 82.4 19.6 19.6 0 year_end_rush
717 2025-12-18 133 113.6 19.4 19.4 0 year_end_rush
676 2025-11-07 112 93.6 18.4 18.4 0 normal
631 2025-09-23 115 97.0 18.0 18.0 0 normal
719 2025-12-20 102 84.1 17.9 17.9 0 year_end_rush
722 2025-12-23 99 116.0 -17.0 17.0 0 normal
670 2025-11-01 84 67.4 16.6 16.6 0 normal
609 2025-09-01 102 86.0 16.0 16.0 0 normal
days MAE review_days
special_period
year_end_rush 7 18.63 5
normal 114 5.40 8
Review threshold (90th percentile): 13.50 units

結果の読み取り

要レビュー日のイベント列を見ると、モデル改善で吸収すべき反復要因と、人が例外判断すべき突発要因を分けられます。供給制約下の出荷実績は潜在需要を表さないため、モデル調整より先に欠品・受注残データとの統合が必要です。誤差上位表は、営業・生産・購買の振り返り会議にも利用できます。

No.070:需要予測を在庫・発注計画に接続する

実務での意味

予測モデルの価値は、欠品を減らしながら在庫と緊急対応費を抑えたときに生まれます。最終ノックでは、予測値を発注点へ変換し、単純な固定発注との違いをシミュレーションします。

分析・モデル化の考え方

リードタイムを L=7L=7 日とし、発注点を

ROPt=i=1Ly^t+i+SS,SS=zσeLROP_t=\sum_{i=1}^{L}\hat{y}_{t+i}+SS, \qquad SS=z\sigma_e\sqrt{L}

で近似します。SSSS は安全在庫、z=1.65z=1.65 は正規近似で片側95%相当、σe\sigma_e は日次残差標準偏差です。ここでは理解しやすいよう、テスト各日を発注判断日として「次の7日間に必要な数量」を評価し、予測ベースと過去28日平均ベースを比較します。

Pythonで確認する

lead_time = 7
z = 1.65
residual_std = test["residual"].std(ddof=1)
safety_stock = z * residual_std * np.sqrt(lead_time)

plan = test[["date", "demand_units", "prediction"]].copy()
plan["actual_lead_demand"] = plan["demand_units"].rolling(lead_time).sum().shift(-(lead_time - 1))
plan["forecast_lead_demand"] = plan["prediction"].rolling(lead_time).sum().shift(-(lead_time - 1))
history_mean = df.set_index("date")["demand_units"].shift(1).rolling(28).mean()
plan["fixed_lead_demand"] = plan["date"].map(history_mean) * lead_time
plan = plan.dropna().copy()

for policy, expected in [("Forecast", "forecast_lead_demand"), ("FixedAverage", "fixed_lead_demand")]:
    plan[f"stock_{policy}"] = plan[expected] + safety_stock
    plan[f"shortage_{policy}"] = (plan["actual_lead_demand"] - plan[f"stock_{policy}"]).clip(lower=0)
    plan[f"surplus_{policy}"] = (plan[f"stock_{policy}"] - plan["actual_lead_demand"]).clip(lower=0)

policy_results = pd.DataFrame({
    "policy": ["Forecast", "FixedAverage"],
    "service_level_pct": [
        100 * (plan["shortage_Forecast"] == 0).mean(),
        100 * (plan["shortage_FixedAverage"] == 0).mean(),
    ],
    "avg_shortage_units": [plan["shortage_Forecast"].mean(), plan["shortage_FixedAverage"].mean()],
    "avg_surplus_units": [plan["surplus_Forecast"].mean(), plan["surplus_FixedAverage"].mean()],
})
print(f"Safety stock: {safety_stock:.1f} units (z={z}, lead time={lead_time} days)")
display(policy_results.set_index("policy").round(2))

fig, ax = plt.subplots(figsize=(8, 4))
x = np.arange(len(policy_results))
ax.bar(x - 0.18, policy_results["avg_shortage_units"], 0.36, label="Shortage")
ax.bar(x + 0.18, policy_results["avg_surplus_units"], 0.36, label="Surplus")
ax.set_title("Inventory Policy Comparison")
ax.set_xlabel("Policy")
ax.set_ylabel("Average units per decision")
ax.set_xticks(x, policy_results["policy"])
ax.grid(True, axis="y", alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
Safety stock: 32.1 units (z=1.65, lead time=7 days)
service_level_pct avg_shortage_units avg_surplus_units
policy
Forecast 73.04 6.35 11.85
FixedAverage 63.48 26.13 23.27

png

結果の読み取り

サービス水準、平均不足、平均余剰を同時に比較すると、精度指標を在庫判断へ翻訳できます。安全在庫を増やせば欠品は減りますが余剰は増えるため、欠品1個と在庫1個の費用を設定して選ぶべきです。なお、この簡易計算は日ごとの独立な判断比較であり、実運用では在庫残、入荷予定、最小ロット、発注間隔、賞味・保管期限を含む在庫推移シミュレーションへ拡張します。

対象ノックを通して見える実務上の示唆

  1. 粒度を意思決定に合わせる:日次・週次・月次は優劣ではなく用途が異なります。
  2. 基準モデルを置く:1週前実績を超えない高度モデルは、運用コストに見合いません。
  3. 時間順序を守る:ランダム分割や未来情報を含む移動平均は、本番精度を過大評価します。
  4. 平均精度だけを見ない:外れる期間と誤差方向が、欠品リスクを左右します。
  5. KPIを業務へ翻訳する:MAEからサービス水準、余剰、特急費へ接続して投資判断します。

実務導入する場合に必要なこと

  • 受注・出荷・欠品・失注・在庫・販促・操業カレンダーの定義と粒度を統一する
  • 予測基準日を定め、その時点で利用可能なデータだけを保存する
  • SKU特性とリードタイム別に、予測期間・更新頻度・評価指標を設計する
  • 精度劣化、欠測、異常値を監視し、担当者が上書きした理由を記録する
  • 欠品費、保管費、廃棄費、最小ロット、能力制約を含む発注ルールを検証する
  • PoCで精度だけを競わず、既存業務との差分、責任分界、定着支援まで設計する

まとめ

No.061〜No.070では、日別実績の観察から、集計、トレンド・季節性、ラグ特徴量、回帰予測、時系列検証、誤差原因分析、在庫政策比較までを一つの流れで確認しました。需要予測は単独のモデルではなく、データ定義、現場知識、評価設計、発注ルールからなる意思決定システムです。まず小さな対象SKUで基準値と例外を可視化し、業務KPIで効果を測ることが実装への近道です。

法人向けのご相談

数理工房では、製造業における需要予測、在庫最適化、生産計画、データ基盤整備、社内研修について、課題整理からPoC、運用設計まで支援しています。「データはあるが発注判断につながらない」「現場で使える評価指標を設計したい」といった段階からご相談いただけます。

📩 お問い合わせ: surikobo.co.jp/contact
まずはお気軽にご相談ください。