きっかけ
私は現在、社内のデータサイエンティスト研修に参加しています。
本記事は、研修を通して考えなくてはいけない事のメモです。最初は「BIを整備する」「需要予測を入れる」あたりを並べればいいだろうと思っていたのですが、書き進めるうちに、それだと現場の何がどう変わるのかを自分で説明できないことに気づきました。
引っかかったのは、たとえばこういう場面です。
夕方18時の惣菜売場。弁当が12個残っていて、閉店まで3時間ある。担当者が棚を見て、20%引きにするか、19時まで待つかを決める。この判断が1店舗で1日100回近くあって、全店・全商品・1年で数え直すと年間数千万回になる。そしてそのほとんどは経験と勘、あとはなんとなくの相場観で決まっています。
一方でダッシュボードのほうには、売上も前年比も客数もPI値も廃棄率もきれいに並んでいます。でもそのどれ一つとして、「20%引きにすべきか」には答えてくれない。
この距離をどう詰めるか、というのがこの記事の中身です。可視化の話ではなく、予測・因果推論・数理最適化を使って意思決定そのものを計算する話になります。惣菜売場を模したダミーデータを作って手元で全部動かしたので、結果もそのまま置いておきます。
想定しているのは小売の企画・情シス・分析まわりの方です。数式は出てきますが、意味は日本語で書きました。それなりに長いので、技術的な中身だけ読みたい方は「4. 使ったダミーデータ」あたりから入っても通じると思います。
1. BIで止まってしまう理由
BIツールを入れるのは間違いなく前進です。ただ、そこには構造的な天井があります。
可視化は、人間の判断能力を超えられません。
ダッシュボードは情報を人間に渡すところで仕事が終わります。最後に決めるのは人間です。ということは、意思決定の質は人間の処理能力で頭打ちになる。
- 発注担当者が200SKUの需要分布を頭の中で確率的に扱えるか。無理です
- 天候・気温・曜日・価格・競合チラシの交互作用を10要因ぶん暗算できるか。無理です
- 全店の値引きタイミングを、残り時間と在庫の関数として毎日引き直せるか。無理です
回数が多くて1回あたりの判断が複雑な領域ほど、人間はスケールしない。逆に「年に1回の出店判断」のような低頻度・高額の決定は、人間が決めるべき領域だと思っています。データは材料を出すだけでいい。
この線引きが、そのまま投資対象を決める最初の軸になりました。
2. 整理に使った軸
分析の3階層
使い古された整理ですが、出発点としては便利でした。
| 階層 | 問い | 例 | アウトプット |
|---|---|---|---|
| Descriptive(記述的) | 何が起きたか | 売上、前年差、客数、PI値 | グラフ・表 |
| Predictive(予測的) | これから何が起きるか | 需要予測、廃棄予測、離反予測 | 予測値・確率 |
| Prescriptive(処方的) | ではどう行動すべきか | 発注量、値引率、人員配置 | 意思決定そのもの |
多くの会社は第1階層にいます。第2階層に手が届いているところもそれなりにある。第3階層まで来ている会社はかなり少ない、という肌感です。
で、書きながら一番腑に落ちたのが、第2階層と第3階層の間には技術的にけっこう深い溝がある、ということでした。この記事の中心はほぼそこの話になります。
自動化のレベル
3階層だけだと「で、システムはどこまでやるの」が曖昧なままなので、自動運転のレベル分けに倣ってもう1軸足しました。
| Lv | 状態 | 人間の役割 |
|---|---|---|
| L0 | 勘と経験 | すべて決める |
| L1 | ダッシュボードを見て決める | 情報を解釈して決める |
| L2 | 推奨値が出て、人が承認 | 承認・修正 |
| L3 | 機械が決定、人は例外のみ | 例外対応 |
| L4 | 完全自動 | 監視 |
現実的な着地はたいていの領域で L2〜L3 でしょう。L4をいきなり狙うと転びます。理由は9章と11章で書きます。
頻度 × 金額で置いてみる
活用領域をただ並べると総花的な提案書になってしまうので、「意思決定の頻度」と「1回あたりの金額」の2軸に置いてみました。これをやると優先順位が勝手に決まります。
右下、つまり発注・製造量と値引きタイミングが、自動化の投資対効果としては一番おいしい。1回の金額は小さいのに回数が桁違いで、しかも人間が最適化しきれていない。効果が粗利に直結していて測定もしやすい、というのも大きいです。
反対に左上の出店判断は、機械が決めるべきではないと思っています。ここでのデータサイエンスは意思決定の支援であって代替ではない。
以降は右下の2つを軸に、技術的な中身を掘っていきます。
3. 使ったダミーデータ
以降の話は全部、合成データで実際に動かして確認しました。惣菜売場を模した設定です。
- 5店舗 × 6商品(弁当・唐揚げ・ポテサラなど) × 730日 = 21,930行
- 曜日・祝日・気温・天候(晴/雨/雪)・価格・値引きフラグ
- 真の需要(現実には観測できない)と、販売実績(観測できる)を作り分ける
- 過去の製造数量は「直近の同曜日の平均くらい」という人手のヒューリスティックで決める
- 完売した場合は完売時刻も記録
真の需要と実績を分けて作っているのがミソです。現実には真の需要は永久に観測できませんが、シミュレーションなら答え合わせができます。この記事の主張のうち検証できているものは、ほぼこの作り分けのおかげです。
データ生成コード(クリックで展開)
"""
惣菜(デリカ)売場を模した合成データの生成
SKU × 店舗 × 日 の粒度
"""
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
# ---- マスタ定義 ----
stores = pd.DataFrame({
"store_id": ["S01", "S02", "S03", "S04", "S05"],
"store_scale": [1.0, 1.35, 0.75, 1.1, 0.9], # 店舗規模係数
})
skus = pd.DataFrame({
"sku_id": ["D01", "D02", "D03", "D04", "D05", "D06"],
"sku_name": ["幕の内弁当", "唐揚げ", "ポテトサラダ", "太巻き", "コロッケ", "焼き鳥"],
"base_price": [498, 398, 198, 398, 98, 158],
"cost_rate": [0.62, 0.58, 0.55, 0.60, 0.52, 0.57],
"base_demand":[26, 40, 18, 14, 55, 32], # 標準店の平日平均需要
"elasticity": [-1.8, -2.2, -1.5, -2.0, -1.6, -2.4], # 真の価格弾力性
})
dates = pd.date_range("2024-01-01", "2025-12-31", freq="D")
# ---- 天候・気温(積雪地の店舗を想定) ----
day_of_year = dates.dayofyear.values
temp_base = 10 + 13 * np.sin(2 * np.pi * (day_of_year - 110) / 365)
temperature = np.round(temp_base + rng.normal(0, 3.0, len(dates)), 1)
weather_p = np.where(temperature < 2, 0.35, 0.18) # 冬は雪/雨が増える
is_bad_weather = rng.random(len(dates)) < weather_p
weather = np.where(is_bad_weather & (temperature < 2), "snow",
np.where(is_bad_weather, "rain", "sunny"))
holidays = {"01-01", "01-02", "01-03", "02-11", "04-29", "05-03", "05-04",
"05-05", "07-15", "08-11", "09-16", "11-03", "11-23", "12-24", "12-31"}
is_holiday = np.array([d.strftime("%m-%d") in holidays for d in dates]).astype(int)
cal = pd.DataFrame({
"date": dates, "dow": dates.dayofweek, "temperature": temperature,
"weather": weather, "is_holiday": is_holiday, "day_of_year": day_of_year,
})
df = (cal.merge(stores, how="cross").merge(skus, how="cross"))
# ---- 価格決定(★内生性を意図的に埋め込む)----
# 現場は「売れなさそうな日ほど事前に値付けを下げる」運用をしている
low_traffic = (df["weather"] != "sunny").astype(int)
promo_score = (0.9 * low_traffic
+ 0.6 * (df["dow"] == 1).astype(int) # 火曜は集客が弱い
- 0.8 * df["is_holiday"]
+ rng.normal(0, 0.5, len(df)))
df["is_promo"] = (promo_score > 0.9).astype(int)
df["price"] = np.where(df["is_promo"] == 1,
np.round(df["base_price"] * 0.85, 0), df["base_price"])
# ---- 真の需要(観測されない)----
dow_effect = np.array([-0.05, -0.12, -0.05, 0.00, 0.15, 0.45, 0.30]) # 月〜日
log_mu = (
np.log(df["base_demand"])
+ np.log(df["store_scale"])
+ dow_effect[df["dow"].values]
+ 0.30 * df["is_holiday"]
- 0.22 * (df["weather"] == "snow").astype(float)
- 0.10 * (df["weather"] == "rain").astype(float)
+ 0.012 * (df["temperature"] - 10) * np.where(df["sku_id"].isin(["D02", "D06"]), 1.0, 0.3)
+ 0.10 * np.sin(2 * np.pi * (df["day_of_year"] - 60) / 365)
+ df["elasticity"] * np.log(df["price"] / df["base_price"]) # ← 真の価格効果
)
df["true_demand"] = rng.poisson(np.exp(log_mu))
# ---- 過去の製造数量(人手のヒューリスティック)----
df = df.sort_values(["store_id", "sku_id", "date"]).reset_index(drop=True)
key = ["store_id", "sku_id", df["dow"]]
hist = df.groupby(key)["true_demand"].transform(
lambda s: s.shift(1).rolling(4, min_periods=1).mean())
hist = hist.fillna(df["base_demand"] * df["store_scale"])
df["production"] = np.maximum(1, np.round(hist * rng.normal(1.10, 0.10, len(df)))).astype(int)
# ---- 観測される実績 ----
df["sales"] = np.minimum(df["true_demand"], df["production"]) # ★打ち切り
df["is_soldout"] = (df["true_demand"] >= df["production"]).astype(int)
df["waste"] = df["production"] - df["sales"]
# ---- 時間帯別の来店(10:00〜21:00、昼と夕方に山)----
OPEN, CLOSE = 10.0, 21.0
def sample_arrival(n):
kind = rng.choice(3, size=n, p=[0.32, 0.48, 0.20])
t = np.where(kind == 0, rng.normal(12.0, 0.9, n),
np.where(kind == 1, rng.normal(17.6, 1.2, n),
rng.uniform(OPEN, CLOSE, n)))
return np.clip(t, OPEN, CLOSE)
# 累積到着比率 F(t)
_grid = np.linspace(OPEN, CLOSE, 45)
_ref = sample_arrival(2_000_000)
F = np.searchsorted(np.sort(_ref), _grid) / len(_ref)
# 完売時刻:製造数 production 個目が売れた時刻
sellout_time = np.full(len(df), np.nan)
dem, prod = df["true_demand"].values, df["production"].values
for i in np.where(df["is_soldout"].values == 1)[0]:
if dem[i] == 0:
continue
ts = np.sort(sample_arrival(dem[i]))
sellout_time[i] = ts[min(prod[i], dem[i]) - 1]
df["sellout_time"] = np.round(sellout_time, 2)
cols = ["date", "store_id", "sku_id", "sku_name", "dow", "is_holiday", "weather",
"temperature", "base_price", "price", "is_promo", "cost_rate",
"production", "sales", "waste", "is_soldout", "sellout_time", "true_demand"]
df[cols].to_csv("deli_dummy.csv", index=False)
np.save("F_grid.npy", np.vstack([_grid, F]))
print(f"行数: {len(df):,}")
print(f"欠品(完売)発生率: {df.is_soldout.mean():.1%}")
print(f"廃棄率: {df.waste.sum() / df.production.sum():.1%}")
行数: 21,930
期間: 2024-01-01 〜 2025-12-31
欠品(完売)発生率: 36.9%
廃棄率: 14.5%
平均完売時刻: 18.59 時
date store_id sku_id sku_name dow weather temperature price production sales waste is_soldout true_demand
2024-01-01 S01 D01 幕の内弁当 0 snow -1.5 498.0 31 29 2 0 29
2024-01-02 S01 D01 幕の内弁当 1 snow -5.6 498.0 29 18 11 0 18
2024-01-03 S01 D01 幕の内弁当 2 snow -0.3 498.0 26 21 5 0 21
欠品率36.9%で廃棄率14.5%。欠品も廃棄も同時に起きている、勘で作っている売場の典型的な姿になりました。パラメータをいじって現実的な水準に寄せたので、ここは意図通りです。
4. 需要予測でだいたい踏む地雷
「需要予測をやります」と言うのは簡単なんですが、素直にやると必ず踏む地雷がいくつかあります。3つに絞って書きます。
販売実績は需要ではない
POSに残っているのは需要ではなく「売れた数」です。15時に売り切れた弁当の本当の需要は実績より多いはずですが、POSには売れた数しか残らない。
統計では打ち切りと呼ばれる状態です。真の値が上から切られている。
この状態で普通に機械学習モデルを学習させるとどうなるか。真の需要で学習したモデルと並べて比較できるのが、ダミーデータの一番ありがたいところです。
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
df = pd.read_csv("deli_dummy.csv", parse_dates=["date"])
FEATURES = ["dow", "is_holiday", "temperature", "price", "is_promo",
"store_code", "sku_code", "month", "weather_code"]
df["store_code"] = df.store_id.astype("category").cat.codes
df["sku_code"] = df.sku_id.astype("category").cat.codes
df["weather_code"] = df.weather.map({"sunny": 0, "rain": 1, "snow": 2})
df["month"] = df.date.dt.month
train = df[df.date < "2025-07-01"]
test = df[df.date >= "2025-07-01"]
def fit(X, y, **kw):
m = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.06,
random_state=0, **kw)
m.fit(X, y)
return m
m_sales = fit(train[FEATURES], train["sales"]) # 実績で学習(現実にできること)
m_true = fit(train[FEATURES], train["true_demand"]) # 真の需要で学習(理想)
p_sales = m_sales.predict(test[FEATURES])
p_true = m_true.predict(test[FEATURES])
actual = test["true_demand"].values
print(f"真の需要の平均 : {actual.mean():.2f} 個")
print(f"実績(sales)学習モデル : {p_sales.mean():.2f} 個 ({p_sales.mean()/actual.mean()-1:+.1%})")
print(f"真の需要で学習モデル : {p_true.mean():.2f} 個 ({p_true.mean()/actual.mean()-1:+.1%})")
so = (test["is_soldout"] == 1).values
print(f"[完売日のみ] 真の需要 {actual[so].mean():.2f} / 実績学習 {p_sales[so].mean():.2f}")
真の需要の平均 : 36.53 個
実績(sales)学習モデル : 34.88 個 (-4.5%)
真の需要で学習モデル : 36.76 個 (+0.6%)
[完売日のみ] 真の需要 41.21 / 実績学習 34.11 (-17.2%)
全体では −4.5% です。ただ完売した日に絞ると −17.2% まで開きます。よく売れる日ほど需要を過小評価する、つまり一番外してほしくない場所で一番外れる。
そのまま閉ループにすると、じりじり縮んでいく
ここからが本題です。この予測モデルの出力どおりに製造したら何が起きるか。予測 → 製造 → 実績 → 再学習、を5世代まわしてみました。
cur = train.copy()
print(f"{'世代':<8}{'平均製造数':>12}{'平均実績':>12}{'欠品率':>10}")
print(f"{'0(現状)':<8}{cur.production.mean():>12.2f}{cur.sales.mean():>12.2f}{cur.is_soldout.mean():>10.1%}")
for gen in range(1, 6):
m = fit(cur[FEATURES], cur["sales"])
pred = np.maximum(1, np.round(m.predict(cur[FEATURES])))
new_sales = np.minimum(cur["true_demand"].values, pred) # 製造数で頭を打つ
cur = cur.assign(production=pred, sales=new_sales,
is_soldout=(cur["true_demand"].values >= pred).astype(int))
print(f"{gen:<10}{cur.production.mean():>12.2f}{cur.sales.mean():>12.2f}{cur.is_soldout.mean():>10.1%}")
世代 平均製造数 平均実績 欠品率
0(現状) 40.50 34.80 38.6%
1 34.80 33.38 65.7%
2 33.39 32.47 74.7%
3 32.47 31.79 79.6%
4 31.79 31.25 82.9%
5 31.25 30.81 85.3%
欠品率が 38.6% から 85.3% まで上がって、製造数は毎世代下がり続けます。しかも止まる気配がない。「予測が下がる → 作る量が減る → 実績が減る → 予測がさらに下がる」という自己増幅ループです。
これは机上の話ではなくて、需要予測を入れた後しばらくして「なぜか欠品が増えた」という話が出るとき、原因はだいたいこれだと思っています。閉ループを目指すなら、ここを最初に潰しておかないと後が続きません。
補正:思いつきでやると逆に悪化する
まず思いつくのは「完売した日を学習データから除外する」でしょう。私も最初にこれを試しました。
完売日除外モデル : 34.10 個 (バイアス -6.7%)
学習データ量 16,410 行 → 10,079 行
−4.5% から −6.7% へ、悪化しています。
理由は落ち着いて考えれば当然で、完売日というのは「よく売れた日」です。それを捨てれば残るのは売れなかった日ばかりになる。打ち切りバイアスを消そうとして選択バイアスを入れてしまった、というだけの話でした。
まともなやり方は3つあります。
- 打ち切り回帰(Tobit / censored likelihood):「完売日の真値は実績以上のどこか」という情報を尤度に組み込む
- 完売時刻を使った復元:完売時刻までに需要の何%が到着していたかで割り戻す
- 代替指標:欠品中のカテゴリ全体の売上や、他店の同一SKUの動きから推定する
ここでは2を試します。ダミーデータに完売時刻を入れてあるので、時間帯別の来店カーブ F(t) で割り戻せます。
grid, F = np.load("F_grid.npy")
tr = train.copy()
frac = np.interp(tr["sellout_time"].fillna(21.0), grid, F) # 完売時点までの到着比率
frac = np.clip(frac, 0.35, 1.0)
tr["demand_hat"] = np.where(tr.is_soldout == 1, tr["sales"] / frac, tr["sales"])
m_cor = fit(tr[FEATURES], tr["demand_hat"])
p_cor = m_cor.predict(test[FEATURES])
print(f"完売時刻補正モデル : {p_cor.mean():.2f} 個 ({p_cor.mean()/actual.mean()-1:+.1%})")
完売時刻補正モデル : 37.29 個 (バイアス +2.1%)
[完売日のみ] : 37.76 (真値 41.21, -8.4%)
−4.5% が +2.1% になりました。完売日に絞っても −17.2% → −8.4% です。完全ではないですが、スパイラルを止めるには十分な水準だと思います。
ここから出てくる要件が、たぶんこの記事で一番実務的に効く話です。
「完売時刻を記録する」というデータ設計上の一手が、モデルの精度を根本から決めてしまう。
ところが多くの小売システムは、欠品を時点情報として持っていません。「その日の閉店時に在庫がゼロだった」しか残らない。この状態では上の補正が原理的に不可能です。
なので需要予測プロジェクトの最初のタスクは、モデル選定ではなく「欠品の発生時刻を記録する仕組みを作ること」であるべきだと思っています。
学習時と推論時で、天候データの性質が違う
これは地味ですが後から効いてきます。
学習に使うのは実績天候です。でも発注時点で分かるのは予報です。「気温が高いと唐揚げが売れる」という関係を実績気温で学習しても、本番では予報気温を入れることになる。予報が外れれば予測も外れます。厄介なのは、この劣化がオフラインのモデル評価の段階では一切見えないことです。
対策自体は単純で、学習のときからその時点で入手可能だった予報値を特徴量に使えばいい。ただそのためには予報のスナップショットを日次で保存する仕組みが要ります。気象データは後から遡って取得できないことが多いので、これも早めに手を打っておく類の話です。
積雪地だとこの影響はさらに大きくて、「降雪予報が出た日は前倒し購買が起きる」という、予報そのものが需要を動かす効果まであります。ここは正直まだうまく扱えていません。
点予測では、そもそも発注できない
最後がいちばん重要なところです。
需要予測の成果物として「明日は35個売れます」という数字が出たとして、では35個作ればいいのか。
そうはならない、というのが次章の話で、これが Predictive と Prescriptive の間にある溝の正体だと思っています。
5. 発注・製造量をどう決めるか
欠品と廃棄は、損の大きさが違う
弁当1個、売価498円、原価309円(原価率62%)で考えます。
- 1個足りなかったときの損失 = 得られたはずの粗利 189円
- 1個余ったときの損失 = 廃棄になった原価 309円
余るほうが1.6倍痛い。ということは、平均需要ちょうどを作ってはいけないことになります。少なめに作るのが正解です。
ではどれくらい少なめか。これには200年前から答えがあります。
新聞売り子モデル(Newsvendor Model)
$$
q^{*} = F^{-1}\left(\frac{C_u}{C_u + C_o}\right)
$$
- $C_u$ = 1個不足したときの損失(逸失粗利)
- $C_o$ = 1個余ったときの損失(廃棄原価)
- $F^{-1}$ = 需要分布の分位点関数
- この $\frac{C_u}{C_u+C_o}$ を臨界比(critical ratio)と呼びます
小売の場合は $C_u = 売価 - 原価$、$C_o = 原価$ なので、分母が $C_u + C_o = 売価$ にきれいに落ちます。つまり、
$$
臨界比 = \frac{売価 - 原価}{売価} = 限界利益率
$$
最適な製造量は、需要分布の「限界利益率パーセンタイル」ということになります。粗利率38%の弁当なら需要分布の38%点。平均(50%点)ではありません。粗利率48%のコロッケなら48%点で、粗利率が高い商品ほど多めに作ってよい。
個人的にはここが一番きれいだと思っている部分で、需要予測の出力は点推定ではなく分布(分位点)でなければならない理由もここにあります。「35個売れます」ではなく「38%点は31個です」を返す必要がある。
分位点回帰
分位点を直接予測するには、損失関数をピンボール損失(pinball loss / quantile loss)に変えます。
$$
L_q(y, \hat{y}) = \begin{cases}
q \cdot (y - \hat{y}) & (y \ge \hat{y}) \
(1-q) \cdot (\hat{y} - y) & (y < \hat{y})
\end{cases}
$$
やっていることは「予測が低すぎたときのペナルティを $q$ 倍、高すぎたときを $(1-q)$ 倍にする」だけです。$q=0.38$ なら多めに外すほうを重く罰する。結果として38%点を学習します。
LightGBMなら objective="quantile", alpha=0.38、scikit-learnなら以下です。
from sklearn.ensemble import HistGradientBoostingRegressor
def fit_q(q):
m = HistGradientBoostingRegressor(loss="quantile", quantile=q,
max_iter=300, learning_rate=0.06, random_state=0)
m.fit(train[FEATURES], train["demand_hat"]) # 打ち切り補正済みラベル
return m
4つの方策を比べる
テスト期間(2025年7〜12月・全店全SKU)で粗利を実際に計算しました。
unit_cost = (test["base_price"] * test["cost_rate"]).values
unit_price = test["price"].values
demand = test["true_demand"].values
def profit(order):
order = np.maximum(0, np.round(order))
sold = np.minimum(demand, order)
return (unit_price * sold - unit_cost * order).sum()
# 商品ごとに臨界比が違うので、商品ごとに分位点を変える
test["critical_ratio"] = 1 - test["cost_rate"]
pred_cr = np.zeros(len(test))
for q in sorted(test["critical_ratio"].round(2).unique()):
mask = (test["critical_ratio"].round(2) == q).values
pred_cr[mask] = fit_q(q).predict(test.loc[mask, FEATURES])
=== 商品別の臨界比(= 限界利益率)===
原価率 臨界比
幕の内弁当 0.62 0.38
唐揚げ 0.58 0.42
ポテトサラダ 0.55 0.45
太巻き 0.60 0.40
コロッケ 0.52 0.48
焼き鳥 0.57 0.43
=== 2025年7〜12月・全店全SKU合計 ===
policy 粗利 廃棄率 欠品率 平均製造 粗利改善
① 現場の勘(現状運用) 13.74 百万円 15.9% 27.2% 41.3 +0.0%
② 平均予測どおり製造 16.70 百万円 7.6% 39.9% 37.3 +21.5%
③ 分位点予測(臨界比) 16.96 百万円 5.8% 51.1% 35.7 +23.4%
④ 真の需要を知る神 20.43 百万円 0.0% 0.0% 36.5 +48.7%
先に断っておくと、①のベースラインは私がこのシミュレーション用に作った人工的なヒューリスティックです。「+21.5%」を実務の期待効果として読まないでください。実際のベテラン主任はこれよりずっと上手です。この表から読むべきなのは改善率ではなく、方策どうしの性質の違いのほうです。
見どころは3つ。
②→③ で廃棄率が 7.6% から 5.8% に下がっています。同じ需要予測モデルを使っていても、どの分位点を取るかで在庫の性格が変わる。
③の欠品率51.1%は、さすがに高すぎます。ここは重要なので後で書きます。
④(神)との差は予測精度の伸びしろです。20.43 と 16.96 の差は、どれだけモデルを磨いても越えられない不確実性ではなく、まだ拾えていない情報の量を表しています。
分位点を振ると、本当に理論値でピークになるのか
理論が正しければ、粗利は臨界比 0.38 の近くで最大になるはずです。幕の内弁当で q を振ってみました。
数値(クリックで展開)
=== 発注に使う分位点 q と粗利の関係(幕の内弁当・臨界比0.38) ===
q 粗利(千円) 廃棄率 欠品率
0.20 3,832 3.0% 73.9%
0.30 3,894 4.6% 61.6%
0.38 3,892 5.9% 53.5%
0.50 3,797 8.3% 39.5%
0.60 3,645 10.6% 29.8%
0.80 3,037 16.8% 13.9%
0.30〜0.38 でピーク、理論どおりでした。そして 0.80(欠品を極度に嫌う運用)まで上げると粗利はピークから22%落ちます。
「欠品を出すな」という現場ルールがどれだけ利益を削っているかが、数字で見えるのはけっこう衝撃でした。
とはいえ、欠品率51%は許容できるのか
理論どおりに解くと欠品率が5割を超えます。これは経理上は正しくても、経営上は正しくない。
原因ははっきりしていて、$C_u$ に逸失粗利しか入れていないからです。実際の欠品にはこういう損失がついてきます。
- その客が代替商品も買わずに帰る(カテゴリ全体の売上減)
- 「あの店は夕方行っても何もない」という評判がつき、来店頻度が落ちる(長期の顧客生涯価値の毀損)
- 特売商品の欠品はクレームになる
なので $C_u = (売価 - 原価) \times \alpha$、$\alpha > 1$ とすべきです。
$\alpha = 2$ とすると幕の内弁当の臨界比は $\frac{2 \times 189}{2 \times 189 + 309} = 0.55$ に上がります。50%点より少し多めに作る、という現場感覚にかなり近い答えになります。
$\alpha$ をどう決めるか
$\alpha$ は「欠品が将来の来店にどれだけ効くか」なので、本来は因果推論で推定すべき量です。ID-POSがあれば、欠品に遭遇した顧客のその後の来店頻度を、遭遇しなかった類似顧客と比べて推定できます。
同時に、実務的には $\alpha$ は経営の意思そのものでもあります。「うちは欠品を許容してでも廃棄を減らす」のか「客数優先で欠品は許さない」のか。この方針を1つの数字に翻訳して全店・全SKUに一貫適用できる、というのは従来の運用ルールではできなかったことです。
構想を書いていて、ここが一番おもしろいテーマだと思いました。
現実の制約
ここまでは制約のない理想形です。実際には最適な数量をそのまま作れません。
| 制約 | 内容 |
|---|---|
| ロット | ケース入数の倍数、最低発注量 |
| 原材料 | 共通原料の在庫上限(唐揚げと焼き鳥で同じ鶏肉を使う) |
| 作業能力 | フライヤー台数、調理時間、人員 |
| 締切 | 発注は前日20時まで。バッチはそれまでに完了必須 |
| リードタイム | 発注から納品まで1〜3日、その間の需要も予測対象 |
これらが入ると単純な分位点計算では解けなくなり、数理最適化ソルバーの出番になります。目的関数は期待粗利、決定変数は各SKUの製造量、制約は上記。PuLP や OR-Tools で書けます。
それと、惣菜にはもう一つ固有の性質があります。1日に複数回の意思決定がある、ということです。
6. 値引きのタイミング
問題設定
18時、弁当が12個残っている。閉店は21時。値引きの選択肢は 0% / 20% / 30% / 50%。
これは「いつ・どれだけ引くか」という逐次意思決定問題です。
早く引きすぎると、定価で買ってくれたはずの客にまで割引してしまう。遅すぎると売れ残って廃棄になり、原価がまるごと消える。
しかもいま値引きすべきかどうかは、このあと何個売れそうかに依存していて、それは残り時間と時間帯別の来店パターンに依存しています。
こういう構造の問題はマルコフ決定過程(MDP)として定式化して、動的計画法で厳密に解けます。
定式化
- 状態 $s = (t, n, d)$:時刻、残在庫、現在の値引率
- 行動 $a$:値引率を選ぶ(一度下げた価格は上げられないので $a \ge d$)
- 遷移:30分間の販売数 $\sim \text{Poisson}(\lambda_t \cdot m(a))$、$m(a) = (1-a)^{-\varepsilon}$
- 報酬:$売価 \times (1-a) \times 販売数$、最終時点で残った分は $-廃棄処理コスト \times n$
$$
V_t(n, d) = \max_{a \ge d} ; \mathbb{E}\Big[ p(1-a)\min(k, n) + V_{t+1}(n - \min(k,n),, a) \Big]
$$
閉店時刻から逆算していけば解けます(後ろ向き帰納法)。
import numpy as np
from scipy.stats import poisson
PRICE, DISPOSAL = 498, 20
DISCOUNTS = [0.0, 0.2, 0.3, 0.5]
ELASTICITY = 1.8
STEPS = ["18:00", "18:30", "19:00", "19:30", "20:00", "20:30"]
LAMBDA = [2.2, 2.0, 1.7, 1.3, 0.9, 0.6] # 30分あたりの来店(定価時)
MAX_INV = 40
T, D = len(STEPS), len(DISCOUNTS)
mult = [(1 - d) ** (-ELASTICITY) for d in DISCOUNTS] # 値引きによる需要増幅率
V = np.zeros((T + 1, MAX_INV + 1, D))
A = np.zeros((T, MAX_INV + 1, D), dtype=int)
V[T] = -DISPOSAL * np.arange(MAX_INV + 1)[:, None] # 終端:廃棄コスト
for t in reversed(range(T)):
for n in range(MAX_INV + 1):
for di in range(D):
best, best_a = -1e18, di
for dj in range(di, D): # 値引率は下げられない
lam = LAMBDA[t] * mult[dj]
k = np.arange(0, n + 1)
pk = poisson.pmf(k, lam)
pk[n] = 1 - poisson.cdf(n - 1, lam) # n以上はまとめる
sold = np.minimum(k, n)
val = (pk * (PRICE * (1 - DISCOUNTS[dj]) * sold
+ V[t + 1][n - sold, dj])).sum()
if val > best:
best, best_a = val, dj
V[t][n][di], A[t][n][di] = best, best_a
出てきた方策
この図が個人的にはこの記事で一番好きな結果です。
値引率は「時刻」だけでも「在庫」だけでも決まっていません。両方の関数になっている。
- 18:00に在庫20個なら、もう30%引く(このペースでは絶対にさばけない)
- 19:30に在庫4個なら、まだ定価(残り時間で十分売れる)
- 境界が対角線状に走っている、つまり「残り時間 × 残り在庫」の比が意思決定を決めている
現場でよくある「19時に20%、20時に半額」という固定ルールは、この表を時刻だけの1次元に潰したものです。悪くはないのですが、在庫の情報を丸ごと捨てています。
差はどれくらいか
=== 方策別の期待成果(1店舗1SKU1日あたり・4万回試行)===
方策 売上 残(廃棄) 改善
① 値引きなし 3,627 4.55 +0.0%
② 固定ルール(19時20%/20時50%) 4,005 1.86 +10.4%
③ DPによる動的最適化 4,277 0.60 +17.9%
固定ルールで +10.4%、動的最適化で +17.9%。廃棄は 4.55個 から 0.60個 まで減りました。
固定ルールと動的最適化の差、+7.5ポイントぶんが「在庫を見て決める」ことの価値です。ここがデータサイエンスで取りに行ける領域だと思っています。
そしてこれが発注より価値が高いと考えている理由は単純で、発注は1日1回ですが値引き判断は1日に何度も、全SKUについて発生するからです。意思決定の回数が桁違いに多く、現場の注意力が最も枯渇している領域でもある。2章のマップで右下に置いたのはそういう意味でした。
実装するとなると
上のモデルはかなり単純化してあります。実務ではこのあたりが乗ってきます。
- カニバリゼーション:弁当を半額にすると隣の丼も一緒に売れなくなる。SKU単独の最適化がカテゴリ利益を損なう可能性がある
- 値引き待ち行動の学習:「19時に行けば半額」と客が学習すると、定価需要が構造的に減る。最適化が需要そのものを変えてしまうという厄介な話で、ランダム性をあえて残す運用が要る場合がある
- リアルタイム在庫:この方策には残在庫が必須。理論在庫と実在庫がずれる売場では成立しない
- オペレーション:値引きシールを貼る人手が要る。3時間で4回も値引率が変わる方策は人が回せない
最後の点は本質的だと思っています。最適解より、実行される次善解のほうが利益が出る。方策を現場が回せる粒度に丸める作業は、たぶんモデルを作るのと同じくらい大事です。
7. 相関で経営判断をすると、逆を向く
ここまでは予測と最適化の話でした。もう一つ、避けて通れないものがあります。
「値引きは効かない」という誤った結論
価格弾力性を推定したいとします。ダミーデータには真の弾力性を仕込んであるので、これも答え合わせができます。
素直に、販売数の対数を価格の対数に回帰します(いわゆる log-log 回帰)。
=== 価格弾力性の推定 ===
商品 真の値 素朴OLS
幕の内弁当 -1.80 -0.82
唐揚げ -2.20 -1.09
ポテトサラダ -1.50 -0.41
太巻き -2.00 -0.95
コロッケ -1.60 -0.65
焼き鳥 -2.40 -1.41
全商品で弾力性が半分以下に推定されました。
これがどれだけ危険か、経営判断の言葉に翻訳してみます。
=== 15%値引きしたときの販売数量の伸び(幕の内弁当)===
素朴OLSの推定 (-0.82) 数量 +14.3% / 売上 -2.9%
真の値 (-1.80) 数量 +34.0% / 売上 +13.9%
素朴な推定を信じると「15%値引きは売上を2.9%減らす」という結論になります。真実は「13.9%増やす」。判断が逆を向いてしまう。
なぜこうなるか
原因を見ればすぐ分かります。
=== 値引きが実施された日の特徴 ===
悪天候率 祝日率
通常価格日 0.135 0.048
値引き日 0.710 0.011
値引き日の71%が悪天候でした。現場が「売れなさそうな日ほど値引きする」という運用をしていたからです(データ生成時にそう埋め込んであります)。
つまりデータには「値引き ↔ 需要が弱い日」という関係が焼き付いている。値引きの効果と悪天候による需要減が混ざってしまう。これを交絡(confounding)、この状況を内生性(endogeneity)と呼びます。
過去の価格データは、ランダムに決まっていません。「売れないから下げた」という判断の結果です。
だから素直に回帰すると「値引きしても大して売れない」という逆向きの結論が出て、経営会議で「価格施策は効果が薄い」という報告になる。分析が意思決定を誤らせる典型例だと思います。
因果推論を販促だけでなく価格にも適用しないといけない理由がここにあります。
Double Machine Learning で補正する
交絡要因(天候・曜日・祝日・気温・店舗・季節)を調整すれば、正しい効果が取り出せます。共変量を入れた線形回帰でもかなり改善しますが、非線形な交絡や交互作用があると足りません。
Double Machine Learning (DML) は、機械学習の予測力を使いながら因果効果だけを不偏に取り出す手法です。手順は拍子抜けするほど単純で、
- 交絡要因 $X$ から結果 $Y$(log販売数)を予測するモデルを作り、残差 $\tilde{Y}$ を取る
- 交絡要因 $X$ から処置 $T$(log価格)を予測するモデルを作り、残差 $\tilde{T}$ を取る
- $\tilde{Y}$ を $\tilde{T}$ に回帰する。この傾きが因果効果
「$X$ で説明できる部分を両方から取り除いて、残ったもの同士をぶつける」というだけです。$X$ の予測にはどんな機械学習モデルを使ってもよく、過学習を防ぐためにクロスフィッティング(データを分割し、他の分割で学習したモデルで残差を作る)を挟みます。
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import KFold
CONTROLS = ["dow", "is_holiday", "temperature", "store_code", "month", "weather_code"]
def dml(d):
Y = np.log(d["demand_hat"].values + 1) # 結果:log 需要
T = np.log(d["price"].values / d["base_price"].values) # 処置:log 価格
X = d[CONTROLS].values
rY, rT = np.zeros(len(d)), np.zeros(len(d))
for tr, te in KFold(5, shuffle=True, random_state=0).split(X):
gY = HistGradientBoostingRegressor(max_iter=200, random_state=0).fit(X[tr], Y[tr])
gT = HistGradientBoostingRegressor(max_iter=200, random_state=0).fit(X[tr], T[tr])
rY[te] = Y[te] - gY.predict(X[te])
rT[te] = T[te] - gT.predict(X[te])
return float((rT @ rY) / (rT @ rT)) # 残差回帰の傾き = 弾力性
数値(クリックで展開)
商品 真の値 素朴OLS 共変量あり DML
幕の内弁当 -1.80 -0.82 -1.81 -1.74
唐揚げ -2.20 -1.09 -2.19 -2.15
ポテトサラダ -1.50 -0.41 -1.57 -1.45
太巻き -2.00 -0.95 -2.04 -1.95
コロッケ -1.60 -0.65 -1.71 -1.63
焼き鳥 -2.40 -1.41 -2.52 -2.43
平均絶対誤差 素朴OLS=1.03 共変量あり=0.06 DML=0.05
平均絶対誤差が 1.03 から 0.05 まで下がりました。正しい弾力性が取り出せています。
この例だと共変量ありの線形回帰でもほぼ同じところに来ているので、DMLの威力が分かりにくいかもしれません。交絡が線形に効くようデータを作った私の責任です。実務ではEconMLの LinearDML や CausalForestDML を使うのが現実的だと思います。
DMLでも解決しない場合
DMLが機能する前提は「交絡要因がすべてデータに入っていること」です。もし現場がデータに残らない情報(「今日は課長がなんとなく客足が悪いと感じた」)で値引きを決めていたら、DMLでも取り除けません。
そのとき残る最後の手段は意図的な価格実験です。一部店舗・一部SKUで価格をランダムに振る。粗利を捨てて情報を買う行為ですが、正しい弾力性が分かれば投資は回収できます。9章の「探索予算」の話につながります。
販促評価とUplift Modeling
同じ枠組みが販促評価にも使えます。「チラシ掲載後に売上が上がった」は因果ではなくて、掲載しなかった場合の売上(反実仮想)との差が効果です。
手段はデータの取れ方で選びます。
| 手法 | 使う場面 |
|---|---|
| A/Bテスト | 施策の割り当てをこちらで制御できる。最強だが実施コストがかかる |
| 差の差法(DiD) | 一部店舗のみ実施。実施店と非実施店の「変化量の差」を見る |
| 傾向スコア | 実施店と非実施店の性質が違うとき、似た店同士を比較する |
| 合成コントロール法 | 実施店が1〜数店しかないとき、非実施店の加重和で仮想の対照店を作る |
| DML / Causal Forest | 交絡が多い、効果の異質性(どの店で効くか)を知りたい |
クーポン配信になると、さらに一段深い問いになります。
購買確率の予測モデルを作って上位者にクーポンを配ると、もともと買う人に値引きを配ることになります。粗利をドブに捨てているのと同じです。
必要なのは「配った場合の購買確率 − 配らなかった場合の購買確率」、つまり Uplift の予測です。顧客は4象限に分かれます。
| 配ると買う | 配ると買わない | |
|---|---|---|
| 配らなくても買う | 無駄(値引き損) | ── |
| 配らないと買わない | 説得可能層(狙うべき) | 無反応層(コスト損) |
理論上は「クーポンが来ると逆に買わなくなる」天邪鬼層もいます。狙うべきは説得可能層だけです。
難しいのはモデルを作ることではなく評価のほうです。同じ顧客の「配った場合」と「配らなかった場合」は両方観測できないので、Qini曲線 / Uplift曲線、あるいは IPS / Doubly Robust 推定量を使うことになります。ここを踏まずに「Upliftモデルを作りました」で終わっている案件は、たいてい効果が検証されていません。
8. 閉ループにしたときの厄介事
目指す姿
ここまでの部品をつなぐと、意思決定が閉じたループになります。
これが「データを見る仕組み」ではなく「意思決定を計算する仕組み」だ、というのが構想メモの結論でした。人間が見るのは例外と結果だけになります。
ただ、この構造には固有の厄介事がいくつかあります。
閉ループは自分の学習データを汚染する
4章で見た縮小スパイラルは、この構造が持つ本質的な性質です。モデルが決めた行動の結果が、次のモデルの学習データになってしまう。
- 10個作れと予測 → 10個しか作らない → 「需要は10個だった」と学習する
- クーポンを配らなかった顧客の反応は、永遠に観測されない
- 棚から外したSKUの需要は、永遠に測れない
バンディット問題でいう探索と活用のジレンマそのものです。
解決策は、たぶん一つしかありません。定常的に、意図的にランダムな行動を混ぜることです。
- 一部の店舗 × SKU × 日で製造量をランダムに上振れさせる → 打ち切りのないクリーンな需要データが取れる
- 一部店舗で価格をランダムに振る → 正しい弾力性が推定できる
- 対象者の一部を意図的に配信除外する(ホールドアウト群) → 販促効果が測れる
探索は機会損失ではなく情報への投資です。
そしてこれは技術ではなく予算と権限の話になります。「なぜわざと余分に作るのか」を経営に説明して探索コストを予算化しておかないと、初年度のコスト削減圧力で真っ先に潰されます。
構想段階で「粗利の◯%を探索予算とする」と明文化しておくべきだと思います。ここを書かずに出したら通らないだろうな、というのが正直な感触です。
予測精度と利益は一致しない
RMSEを1%改善しても粗利が1円も増えない、というのは普通に起こります。
理由は簡単で、誤差の方向によって損失の大きさが違うからです。原価率62%の商品では1個の過剰と1個の不足に1.6倍の非対称がありますが、RMSEはこの非対称を見ていません。
ここから2つ出てきます。
まず、評価指標を意思決定に合わせること。
| 指標 | 使いどころ | 注意 |
|---|---|---|
| MAPE | 直感的 | 実需ゼロで無限大に発散する。SKU×店舗×日ではゼロ需要が大量に出るので実質使えない |
| WAPE | 全体誤差の把握 | 大口SKUに引っ張られる |
| ピンボール損失 | 分位点モデルの評価 | 発注に使うならこれ |
| 粗利・廃棄率・欠品率 | 最終評価 | 最後はこれで測る |
「MAPEで評価してモデルを選んだら、ゼロ需要SKUで壊れた」は、この分野で一番よく聞く失敗です。
もう一歩進めると、損失関数そのものを意思決定コストに合わせるという方向があります。decision-focused learning(SPO+、end-to-end newsvendor など)と呼ばれている領域で、「予測精度を上げてから最適化する」のではなく「最終的な意思決定の損失を直接最小化するように予測モデルを学習する」という発想です。私はまだ実装したことがないので偉そうなことは言えませんが、構想段階でここまで射程に入れておくと文書の性格が変わると思います。
間欠需要と階層整合性
細かいですが、実務では必ず刺さる2点です。
SKU×店舗×日の粒度だと販売数ゼロが大量発生します(間欠需要)。通常の回帰では扱いづらく、Tweedie目的関数やゼロ過剰モデル、あるいは販売間隔自体をモデル化する Croston 系の手法が必要になります。
もう一つが階層整合性で、店舗別の予測を足し上げてもセンターの発注計画とは一致しません。予測を階層構造(全社 > 地区 > 店舗 > SKU)で整合させる MinT などの調整手法が要ります。整合していない予測は物流計画に使えません。
新商品・新店舗のコールドスタート
惣菜は改廃が激しいので、販売履歴のない商品が常に一定割合あります。過去データがないので予測できません。
対策は「SKU ID」ではなく商品属性(カテゴリ、主原料、価格帯、内容量、調理法)で学習することです。属性で学習しておけば新商品も属性から需要が推定できます。新店舗も同様に、商圏属性から類似店を探して需要を借りてきます。
9. ほかの領域はどうなるか
深掘りは3領域に絞りましたが、同じ「予測 → 最適化 → 効果検証」の構造は他にも展開できます。全体マップとして置いておきます。
| 領域 | 階層 | 主な手法 | 難易度 |
|---|---|---|---|
| 需要予測 | Predictive | 分位点GBDT、打ち切り補正 | 中 |
| 発注・製造量最適化 | Prescriptive | 新聞売り子、数理最適化 | 中 |
| 値引きタイミング最適化 | Prescriptive | MDP、動的計画法 | 中 |
| 価格最適化 | Prescriptive | 弾力性推定(DML)+ 最適化 | 高 |
| 販促効果測定 | ── | A/B、DiD、合成コントロール、DML | 中 |
| クーポン対象者最適化 | Prescriptive | Uplift Modeling、予算制約付き割当 | 高 |
| 顧客分析(離反・LTV) | Predictive | 生存時間解析、GBDT | 中 |
| レコメンド | Predictive | 協調フィルタリング、系列モデル | 中 |
| 棚割り・品揃え最適化 | Prescriptive | 併買分析、需要転移モデル、組合せ最適化 | 高 |
| 人員配置最適化 | Prescriptive | 業務量予測 + シフト最適化(MIP) | 中 |
| 廃棄予測 | Predictive | 分類・回帰 | 低 |
| 異常検知 | Descriptive+ | 統計的管理図、Isolation Forest | 低 |
| 商品開発 | Descriptive+ | テキスト分析、属性ベース需要推定 | 高 |
| 出店・商圏分析 | Predictive | GIS、Huffモデル、空間統計 | 高 |
| 物流・在庫配置最適化 | Prescriptive | 多段階在庫、輸送問題、VRP | 高 |
このリストに入れるかどうか迷って、結局入れた領域も書いておきます。
当日の製造追加判断。 発注は前日に1回ですが、惣菜の製造は当日に複数回あります。「14時時点でこの売れ方なら、16時にもう1回揚げるか」という、リードタイムが分単位で1日に何度も来る意思決定です。小売DSの記事はほぼ全部「発注」を扱っていて、当日の追加製造を扱ったものをあまり見かけません。惣菜部門の現場を知っている人だけが書ける領域だと思いますし、値引き最適化と同じMDPの枠組みで解けます。個人的には構想の目玉にする価値があると思っています。
品揃え最適化と需要転移。 棚割り(どこに置くか)とは別に、品揃え(何を置くか)という問題があります。核心はカットしたSKUの需要がどこへ流れるか。他SKUに移るなら削減の価値がありますが、店ごと離反するなら大損です。多項ロジットなどの選択モデルで需要転移行列を推定します。
理論在庫と実在庫の乖離(シュリンケージ)。 発注最適化はすべて「システム上の在庫が正しい」ことを前提にしています。実際には棚卸ロス、登録ミス、廃棄の未登録、盗難で乖離する。この乖離を予測して補正しないと上流の最適化が全部ずれます。地味ですが、導入初期に一番効く改善がこれ、というケースもあり得ます。
チラシ紙面の最適化。 「どの商品を、どの大きさで掲載するか」は組合せ最適化問題です。商品ごとの集客効果と併買誘発効果を推定して、紙面という制約下で最適配分する。
リテールメディア / データ提供。 需要予測基盤の副産物として、メーカーに「この商品は雨の日に◯%落ちる」といった知見を提供できます。コスト削減だけでなく収益源としてのデータ活用を構想に入れられると、投資判断の文脈が変わります。ここは書きながら思いついた話なので、詰めきれていません。
10. 現場に持っていくと詰まるところ
技術的な絵が描けても、実際に動かすときに詰まるのはほぼ全部ここです。
データ
| 課題 | 影響 |
|---|---|
| 欠品発生時刻がない | 打ち切り補正が原理的に不可能。4章の議論が全部使えない |
| リアルタイム在庫がない | 値引き最適化が成立しない(残在庫が状態変数なので) |
| 理論在庫 ≠ 実在庫 | 上流の最適化がすべてずれる |
| 廃棄データの粒度が粗い | 部門合計でしか取れないとSKU別最適化が評価できない |
| 値引きがレジ手打ち | 値引率が構造化データとして残らず、弾力性が推定できない |
| 商品マスタの改廃・JAN使い回し | 時系列がつながらない。同一商品の名寄せが必要 |
| 販促マスタが非電子 | 過去のチラシ掲載履歴が紙。因果推論の処置変数が作れない |
| 天候予報の履歴がない | 予報値で学習できず、本番で劣化する |
構想段階でやるべきなのは、モデルの設計よりデータの棚卸しだと思います。上のうち何が取れて何が取れないかで、実現可能な領域が決まってしまう。取れないデータがあるなら、そのデータを取る仕組みを作ること自体が第1フェーズになります。そしてこれはモデル開発より時間がかかります。
組織
推奨値の上書き率が実効性能を決める。 L2(推奨・人が承認)の運用では、店舗が推奨値を上書きできます。上書き率が80%なら、どんなに良いモデルでも効果は出ません。上書き率を最重要KPIとして計測すべきです。
そして上書きされた理由を集める。「明日は近所で運動会」のような、モデルが知らない情報を現場が持っているなら、それは責めるべきことではなく特徴量として取り込むべき情報です。
評価指標の衝突。 バイヤーや店長の評価が売上ベースなら、廃棄削減は個人の評価を下げる行為になります。現場のインセンティブと最適化の目的関数が矛盾していると、システムは必ず無視されます。技術ではなく人事制度の問題ですが、ここを見ないプロジェクトは失敗すると思います。
説明可能性。 「なぜ今日は20個なのか」に答えられないと現場は使いません。SHAPで「気温が低いため −3個、金曜のため +5個」程度の要因分解を返すだけで受容度は大きく変わります。精度より納得感が優先される場面がある、というのは技術者としては受け入れにくいのですが、使われないモデルの精度はゼロと同じなので。
運用
- 計算時間の制約は硬い。発注締切が19時ならバッチは18時までに終わっていなければならない。SKU 3万 × 店舗200 × 予測日数の規模だと、これが設計上の主要制約になります
- フォールバック経路は必須。障害時に「前年同曜日の実績 × 補正係数」で代替できるようにしておく。止まると店が回りません
- ドリフト検知。競合の出店、価格改定、商品リニューアルでモデルは劣化します。予測誤差の分布を監視して、閾値超過でアラートと再学習
- 介入権限の設計。災害、大型イベント、感染症流行のような異常時に、人間が上書きできる経路と権限を先に決めておく
- 段階的な自動化。L2で数ヶ月運用し、上書き率と精度を見てから、特定カテゴリだけL3に上げる
11. 効果測定を先に設計しておく
構想を書いていて、最後に一番効くと思ったのがこれでした。
全店一斉に導入すると、効果を証明できなくなります。比較対象が消えるからです。「導入後、廃棄が5%減りました」と言っても、「天候が良かっただけでは?」「同時期の価格改定の影響では?」に反論できない。そして効果を証明できないプロジェクトは、次年度の予算がつかずに消えます。
なので導入そのものを実験としてデザインします。
| 方法 | 内容 | 適する場面 |
|---|---|---|
| クラスタ無作為化 | 店舗を規模・立地でクラスタリングし、クラスタ単位で導入/非導入をランダム割当 | 店舗数が十分にある(数十店以上) |
| スイッチバック | 同一店舗で期間ごとに新旧を切り替える | 店舗数が少ない |
| 段階的展開(stepped-wedge) | 全店に導入するが、開始時期をランダムにずらす | 「全店導入は決定事項」という政治的制約があるとき |
7章で販促効果に使った因果推論を、自社のデータサイエンス導入そのものの効果測定に使う。これが構想として一番一貫した姿だと思っています。
測る指標はモデル精度ではなく経営指標です。
- 粗利額(最重要)
- 廃棄率、欠品率
- 発注作業時間(見落とされがちですが、労働力不足の小売では極めて大きい)
- 推奨値の上書き率
12. やらないことを決めておく
技術的に可能でも、やってはいけないことがあります。構想段階で線を引いておく話です。
- ダイナミックプライシングの受容性。同じ商品が時間帯や客によって違う価格になることへの心理的抵抗はあります。惣菜の時間帯値引きは既に社会的に受容されていますが、顧客属性による価格差別は一線を越えると思います
- ID-POS利用の説明責任。個人情報保護法上の利用目的の明示。「購買履歴から健康状態が推定できてしまう」ようなセンシティブ推論の扱い
- クーポン最適化の公平性。Uplift最適化を突き詰めると「反応しない顧客」には何も配られなくなります。効率的ではありますが、特定層の体系的な排除になっていないかは確認が要ります
- 人員配置最適化と働き方。最適化されたシフトが従業員にとって受け入れがたい形(細切れ勤務、不規則化)になる可能性があります。目的関数に従業員の満足度を入れないと、離職率で跳ね返ってきます
まとめ
書きたかったことは一つで、「データを見る仕組み」から「意思決定を計算する仕組み」へ、ということです。
そのために越える技術的な関門を、この記事では4つ扱いました。販売実績は需要ではないので打ち切りを補正しないと閉ループが縮小スパイラルに入ること(欠品率 38.6% → 85.3%)。点予測では発注できず、必要なのは分布で、最適発注量は需要分布の限界利益率パーセンタイルであること。値引きは「残り時間 × 残り在庫」の関数で、時刻だけの固定ルールは情報を捨てていること(動的最適化で +17.9%)。そして過去の価格はランダムに決まっていないので、素朴な弾力性推定は判断を逆向きにすること(−2.9% vs +13.9%)。
ただ、書き終えてみると技術より重いのは後半のほうでした。欠品時刻やリアルタイム在庫といったデータがそもそも無いこと。推奨値の上書き率と現場のインセンティブ設計。探索予算を確保しないと閉ループが自分の目を潰すこと。効果測定を先にデザインしないと、成功しても証明できないこと。このへんは全部、モデルの精度とは何の関係もありません。
小売には意思決定の回数という資産があります。1日に数千万回の判断があって、そのそれぞれに数円から数百円の改善余地がある。製造業や金融業にはない形の機会だと思っています。
BIはその意思決定を人間に委ねました。次にやるのは、意思決定そのものを計算することじゃないか、というのがいまの結論です。
長くなりました。間違いや、実務ではそうならないという指摘があればコメントでもらえると嬉しいです。特に当日追加製造まわりは、やっている会社の話をぜひ聞きたいです。
キーワード
- 新聞売り子モデル / Newsvendor Problem
- 分位点回帰 / Pinball Loss(LightGBM
objective="quantile") - 打ち切り回帰 / Tobit Model / Censored Demand Estimation
- 動的計画法 / Markov Decision Process / Revenue Management
- Double Machine Learning / Causal Forest(EconML)
- Uplift Modeling / Qini曲線 / Doubly Robust推定
- 階層時系列予測 / MinT
- Decision-Focused Learning / Smart "Predict, then Optimize" (SPO+)
- 数理最適化(PuLP / OR-Tools / Gurobi)
使用ライブラリ
本記事のコードは numpy / pandas / scikit-learn / scipy だけで動きます。実務では LightGBM、EconML、OR-Tools への置き換えを推奨します。





