📦

需要予測における打ち切り問題 ②理論編・前編 ─ なぜ普通の予測モデルは系統的に間違えるのか

に公開

全 5 回シリーズの 第 2 回。前回:第 1 回(導入編)/次回:第 3 回(理論編・後編)。全体構成は第 1 回冒頭を参照。

1. 前回の振り返りと今回のゴール

第 1 回では、「棚が空になった日の販売数量は、真の需要ではなく、在庫上限で打ち切られた値である」 という事実を、令和の米騒動を題材に説明しました。POS データを鵜呑みにして予測モデルを組むと、需要を系統的に過小評価し、欠品 → さらなる過小予測 → 欠品、という負のスパイラルに陥る、と論じました。

ここまでは定性的な話でした。本記事ではいよいよ Python を開きます。ただし目的は「モデルを作ること」ではなく、「問題の大きさを数値とグラフで実感すること」です。

具体的には次の 3 段階で進めます。

  1. 似て非なる 3 つの概念(打ち切り・切り捨て・サンプル選択)を整理する
  2. 合成データを作って、打ち切りを可視化する
  3. 普通の OLS簡易 Tobit の推定結果を並べて、差を目で見る

途中で数式が出てきますが、第 3 回で本格的に扱うので、ここでは「式の形を眺めて、何が起きているかを想像する」レベルに留めます。

2. 打ち切り、切り捨て、サンプル選択 ― 似て非なる 3 つの概念

第 1 回では便宜上「打ち切り」という言葉だけを使いましたが、統計学の文献では次の 3 つの概念が厳密に区別されています。混同されがちなので、ここで整理しておきましょう。Breen (1996)[2] の第 1 章を踏襲した整理です。

概念 定義 米騒動での対応例
Censoring(打ち切り) 閾値を超えた観測値は閾値で置き換えられるが、観測自体は残る。説明変数(日付、気温、曜日など)は全観測で分かる。 棚が空になった日に「販売 50 袋・日付あり・気温あり」と記録される
Truncation(切り捨て) 閾値を超えた観測値は完全にデータから消える。説明変数すら残らない。 POS システムが停止し、その日のログが存在しない
Sample selection 観測されるか否かが、別の変数 z に依存する 会員カード登録者のみの POS(未登録客の需要は記録されない)

本シリーズでは主に censoring を扱います。なぜなら、米騒動に限らず小売・流通の「在庫切れによる需要打ち切り」問題は、構造的にほとんど censoring だからです。POS データには「その日、閾値以上需要があった」という事実そのものは(在庫データとの突合で)復元可能な形で残っています。

Sample selection は第 3 回で簡単に触れ、実際に米騒動期の「1 家族 1 袋」という店舗ルールで需要観測が歪む事例に言及します。

3. Python による最初の実験:真の需要と観測される売上

では合成データを作ってみましょう。合成データを使う理由は明快です。真の需要(y^*)が分かっているデータで実験しない限り、モデルの推定誤差を正確に評価することはできないからです。

以下のコードは Google Colab(Python 3.11 以上)でそのまま動きます。

generate_synthetic_data.py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

np.random.seed(42)
n_days = 365  # 1年分の日次データ

# ----- 真の需要:トレンド+月次の季節性+ノイズ -----
trend = np.linspace(100, 120, n_days)                          # 年間で緩やかに増加
seasonal = 20 * np.sin(2 * np.pi * np.arange(n_days) / 30)     # 30日周期(月サイクル)
noise = np.random.normal(0, 10, n_days)                        # ガウスノイズ
true_demand = trend + seasonal + noise                         # これが「真の需要」

# ----- 店舗のキャパシティ(在庫上限) -----
capacity = np.full(n_days, 200.0)
# 「令和の米騒動」に相当する打ち切り期間(150日目〜239日目、約3か月)
capacity[150:240] = 60

# ----- 観測される売上 -----
sales = np.minimum(true_demand, capacity)
is_censored = sales >= (capacity - 1e-6)  # 売上がキャパシティに達した日

df = pd.DataFrame({
    "day": np.arange(n_days),
    "true_demand": true_demand,
    "sales": sales,
    "capacity": capacity,
    "is_censored": is_censored,
})

print(df.head(5))
print(f"打ち切り日数: {is_censored.sum()} / {n_days} 日")

出力される打ち切り日数は 90 日(150 日目〜239 日目の米騒動期)です。実際の令和の米騒動も 2024 年 8 月〜10 月にかけて 3 か月近く品薄が続いたので、これに近い設定にしています。平常期はキャパシティが 200 と十分大きいため、打ち切りは発生しません。

次に、3 本の線を重ねて描画します。

plot_three_lines.py
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df["day"], df["capacity"], label="在庫キャパシティ (c)", color="tab:red", linestyle="--", zorder=2)
ax.fill_between(
    df["day"], df["sales"], df["true_demand"],
    where=df["is_censored"], alpha=0.25, color="tab:orange",
    label="打ち切られた需要", zorder=1,
)
ax.plot(df["day"], df["true_demand"], label="真の需要 (y*)",
        color="black", linewidth=2.2, alpha=0.9, zorder=3)
ax.plot(df["day"], df["sales"], label="観測された売上 (y)", color="tab:blue", linewidth=2.2, zorder=4)
ax.axvspan(150, 240, alpha=0.1, color="red", label="米騒動期")
ax.set_xlabel("日 (day)")
ax.set_ylabel("数量 (袋)")
ax.legend(loc="upper left")
ax.set_title("真の需要 / 観測売上 / キャパシティの関係")
plt.show()


このグラフで強調したいのは、次の 3 点です。

  1. 平常期(0〜149 日、240〜364 日)は、売上線(青)と真の需要線(黒)がほぼ一致している。
  2. 米騒動期(150〜239 日)では、売上線(青)が キャパシティ線(赤破線)に張り付く。真の需要(黒)はキャパシティより上にあるが、売上には現れない。
  3. オレンジの塗りつぶし部分こそが、「データから失われた需要」である。

4. 実験その 1:素朴な OLS で予測するとどうなるか

ここで簡単な実験を一つ。上で作った sales 列を「真の需要だ」と仮定して、普通の線形回帰でトレンドと季節性を推定してみます。

これは、実務で普通に行われる分析です。多くのデータサイエンティストは、日次の売上データを与えられたら、真っ先に OLS でトレンドと季節性を当てはめにかかります。

naive_ols.py
import statsmodels.api as sm

# 説明変数:線形トレンド + 1周期の sin/cos
X_design = np.column_stack([
    np.arange(n_days),
    np.sin(2 * np.pi * np.arange(n_days) / 30),
    np.cos(2 * np.pi * np.arange(n_days) / 30),
])
X_design = sm.add_constant(X_design)

# --- 方針A: 打ち切りを無視して全データで回帰 ---
model_naive_all = sm.OLS(df["sales"], X_design).fit()

# --- 方針B: 打ち切られた日を除いて回帰 ---
mask = ~df["is_censored"].values
model_naive_uncens = sm.OLS(df["sales"][mask], X_design[mask]).fit()

# --- 参考: 真の需要に対する OLS(本来は観測不能) ---
model_oracle = sm.OLS(df["true_demand"], X_design).fit()

print("=== 真の需要を使った回帰(オラクル) ===")
print(model_oracle.params)

print("\n=== 方針A: 全データで OLS ===")
print(model_naive_all.params)

print("\n=== 方針B: 打ち切り日を除いた OLS ===")
print(model_naive_uncens.params)

典型的な出力は次のようになります(乱数シードによって多少ぶれます)。

パラメータ オラクル(真の需要基準) 方針 A(全データ OLS) 方針 B(打ち切り除外 OLS)
切片 99.08 88.90 98.70
トレンド係数 0.0605 0.0461 0.0605
sin 係数 20.26 15.59 20.53
cos 係数 1.39 1.14 0.98

切片・トレンド係数・sin 係数(季節性の振幅)が、どれも真の値より小さい方向にずれている ことが見て取れます。具体的には、方針 A ではトレンド係数が約 24% 過小推定され、季節性の振幅も 20 以上あるはずのところが 15.6 程度に縮んでいます(約 23% の縮小)。

なぜ方針 B(打ち切り日を除外)でもバイアスが完全には取れないのか? これは Breen (1996)[2:1] の第 2 章でも詳しく論じられている重要な点です。直感的に言えば、打ち切り日を除外するということは、「需要がキャパシティ未満だった日だけ」 を使って回帰しているわけで、これは真の需要の分布から非対称に切り取られた(truncated な)サンプルを使っていることになります。残ったサンプルの「需要」には、もはや E[u \mid x] = 0 という OLS の前提が成立しません。

SALT2 について

本シリーズを執筆している SALT2 は、生成 AI・予測モデル・最適化を組み合わせたオーダーメイド AI ソリューションを提供する AI スタートアップです。需要予測や在庫最適化のような ML プロジェクトに加え、AI Agent によるコンサルティング業務 DX、非構造化データの自律的な構造化パイプライン、Media 構築など、幅広い領域で企業課題の解決を支援しています。2025 年 10 月にはブーストコンサルティング株式会社のグループ会社となり、戦略コンサルティングの知見と最先端の技術力を掛け合わせる体制を整えました。提供事例は SALT2 の事例ページ で公開しています。

5. なぜバイアスが生じるのか:条件付き期待値という視点

ここで、今回唯一の軽い数式を一つだけ紹介させてください。眺めるだけで構いません

売上 y の期待値は、次のように 2 つの世界の加重平均で書けます。

E[y \mid x] = \underbrace{E[y^* \mid x, y^* < c] \cdot P(y^* < c)}_{\text{需要が在庫に収まる世界}} + \underbrace{c \cdot P(y^* \geq c)}_{\text{需要が在庫を超えて打ち切られる世界}}

ここで y^* は真の需要、c はキャパシティです。

観測される売上の期待値は、2 つの世界の加重平均になっている。第 1 項は「需要がキャパシティ未満で素直に観測される世界」、第 2 項は「需要がキャパシティ以上で天井に張り付く世界」。普通の OLS は暗黙に「y は線形関数 x'\beta に等しい」と仮定して推定するため、第 2 項の構造を無視してしまい、係数が歪む。

重要なのは、第 2 項の c \cdot P(y^* \geq c) が「x に応じて変化する」ことです。x が大きい日(例えば週末で需要が盛り上がる日)は P(y^* \geq c) も大きくなり、売上が天井で打ち切られやすくなります。つまり、打ち切りは「x に対して一様に起きるノイズ」ではなく、「x の値によって発生確率が変わる系統的なバイアス源」なのです。

だからこそ、OLS は系統的にずれるわけです。ずれるのは「うちのデータが特殊だから」ではなく、回帰モデルが打ち切りの存在を想定していないという数学的な構造的理由によるものです。

6. Tobit モデルの直感的な紹介

ここで、本シリーズの主役である Tobit モデル を導入します。

Tobit モデルは 1958 年に経済学者 James Tobin が提案しました[1:1]。当時 Tobin が対象にしていたのは、米騒動とは逆方向の打ち切り問題、「世帯の耐久消費財支出」でした。具体的には次のような状況です:

  • 多くの世帯について、その年の耐久消費財(自動車、家電など)支出を調査した
  • 調査した年に何も買わなかった世帯は「支出 = 0」と記録される
  • これは「支出が 0 円ちょうど」という観測ではなく、「潜在需要が閾値以下だったので表面化しなかった」と解釈される

Tobin のモデルでは、潜在変数 y^*(本当の支出意欲、マイナスも許容)を考え、それが閾値(この場合は 0)を超えたときに限って支出 y として観測される、とします。

y = \begin{cases} y^* & y^* > 0 \\ 0 & y^* \leq 0 \end{cases}

米の例では、方向が逆です。真の需要 y^* がキャパシティ上限 c超えたときに観測が c で打ち切られます。

y = \begin{cases} y^* & y^* < c \\ c & y^* \geq c \end{cases}

しかし、数学的には同じ構造を持ちます。どちらも「潜在変数 y^* の一部だけが観測される」というモデルです。本シリーズでは、この上からの打ち切り(censoring from above)を扱います。

この「部分情報として使う」が、Tobit と OLS を決定的に隔てる発想です。

  • OLS:「y = 50 と観測された」→ とにかく 50 を予測したい
  • Tobit:「y = 50 と観測され、かつこれは打ち切り値である」→ 真の y^*50 以上のどこか にある。この『以上のどこか』という情報を尤度に組み込む

7. Tobit 推定を簡単に試す

残念ながら statsmodels には直接の Tobit 推定器が同梱されていません(2026 年 4 月時点)。そこで、短い自作の対数尤度関数を書いて、scipy.optimize.minimize で最大化してみます。

simple_tobit.py
from scipy.optimize import minimize
from scipy.stats import norm
import numpy as np

def tobit_neg_log_likelihood(params, X, y, c, is_censored):
    """
    Tobit モデルの負の対数尤度(上からの打ち切り版)。
    
    params: [beta_0, beta_1, ..., beta_K, log_sigma]
    X: 説明変数行列 (n, K+1)
    y: 観測された売上 (n,)
    c: キャパシティ (n,)
    is_censored: 打ち切りフラグ (n,)
    """
    beta = params[:-1]
    log_sigma = params[-1]
    sigma = np.exp(log_sigma)
    mu = X @ beta

    # 打ち切られていない観測 → 正規密度関数
    ll_uncensored = norm.logpdf(y[~is_censored], mu[~is_censored], sigma)

    # 打ち切られた観測 → 上側生存確率(1 - Φ)
    # logsf は log(1 - cdf) を数値安定に計算する関数
    ll_censored = norm.logsf(c[is_censored], mu[is_censored], sigma)

    return -(ll_uncensored.sum() + ll_censored.sum())

# 初期値:OLS の結果を初期値として与える
init = np.concatenate([model_naive_all.params.values, [np.log(10)]])

result = minimize(
    tobit_neg_log_likelihood, init,
    args=(X_design, df["sales"].values, df["capacity"].values, df["is_censored"].values),
    method="L-BFGS-B",
)

tobit_params = result.x[:-1]
tobit_sigma = np.exp(result.x[-1])
print("=== Tobit 推定結果 ===")
print(f"切片: {tobit_params[0]:.3f}")
print(f"トレンド係数: {tobit_params[1]:.4f}")
print(f"sin 係数: {tobit_params[2]:.3f}")
print(f"cos 係数: {tobit_params[3]:.3f}")
print(f"残差 sigma: {tobit_sigma:.3f}")

前節と同じシードで実行した場合、Tobit の推定値は典型的に次のようになります。

パラメータ オラクル OLS(全データ) Tobit
切片 99.08 88.90 99.14
トレンド係数 0.0605 0.0461 0.0604
sin 係数 20.26 15.59 20.32
cos 係数 1.39 1.14 1.37
補足:なぜ最適化がうまくいくのか?

Tobit の対数尤度関数は、適切にパラメータを再パラメータ化したもとで 大域的に凹(globally concave) であることが、Olsen (1978)[4] によって証明されています。大域的凹性は、「ローカルミニマがない、どこから始めても同じ頂点にたどり着く」ことを意味します。そのため、初期値を多少雑に与えても L-BFGS-B のような一般的な数値最適化で安定的に解が得られます。

これは実務上、非常に嬉しい性質です。ニューラルネットのように初期値や学習率で結果がころころ変わるモデルとは違い、Tobit の推定は再現性が高いのです。詳しい証明と、log-reparametrization の工夫については第 3 回で紹介します。

8. 予測精度も比べてみる

推定したパラメータを使って、真の需要を予測する精度も比較してみましょう。

rmse_comparison.py
# 各モデルで真の需要を予測(説明変数は同じ X_design)
pred_ols = model_naive_all.predict(X_design)
pred_tobit = X_design @ tobit_params

# 真の需要との二乗平均誤差
def rmse(y_true, y_pred):
    return np.sqrt(((y_true - y_pred) ** 2).mean())

rmse_ols = rmse(df["true_demand"], pred_ols)
rmse_tobit = rmse(df["true_demand"], pred_tobit)

print(f"OLS の RMSE: {rmse_ols:.2f}")
print(f"Tobit の RMSE: {rmse_tobit:.2f}")

# 米騒動期だけに絞った RMSE
mask_crisis = (df["day"] >= 150) & (df["day"] < 240)
rmse_ols_crisis = rmse(df["true_demand"][mask_crisis], pred_ols[mask_crisis])
rmse_tobit_crisis = rmse(df["true_demand"][mask_crisis], pred_tobit[mask_crisis])
print(f"\n[米騒動期のみ] OLS の RMSE: {rmse_ols_crisis:.2f}")
print(f"[米騒動期のみ] Tobit の RMSE: {rmse_tobit_crisis:.2f}")

典型的な出力例:

OLS の RMSE: 16.29
Tobit の RMSE: 9.41

[米騒動期のみ] OLS の RMSE: 17.44
[米騒動期のみ] Tobit の RMSE: 10.06

注目すべきは、米騒動期では OLS の予測誤差が Tobit の約 1.7 倍に膨らむことです。OLS は米騒動期の需要を過小評価するため、騒動期の RMSE が大きくなります。一方 Tobit は、米騒動期でも平常期とほぼ同程度の予測精度を維持できます。これは、Tobit が「打ち切りが起きていた期間だからこそ、真の需要はもっと上」と尤度で推定できるためです。

実務的には、イベント期・異常期の予測精度の改善 こそが、打ち切り補正の最大の恩恵であることが多いです。平常期は OLS でも大差ないことがしばしばあります。しかし、在庫決定が本当に効いてくるのは、実はまさにイベント期や異常期なのです。同じ発想は近年、指数平滑化法(ETS)への打ち切り対応としても再定式化が進んでいます[5]

9. 本回のまとめと次回予告

  1. Censoring / Truncation / Sample selection の 3 概念を区別し、本シリーズは censoring を中心に扱う。
  2. 合成データで「真の需要 / 売上 / キャパシティ」の 3 本線を描くと、打ち切り期間は売上がキャパシティに張り付く。
  3. 普通の OLS は、打ち切りを無視すれば系統的な下方バイアス、打ち切りを除外しても truncated なサンプルになるためバイアスが残る。
  4. Tobit モデル[1:2] は、打ち切られた観測を「c 以上のどこかにある」という部分情報として尤度に組み込むことで、真のパラメータに近い推定を得られる。
  5. 予測精度(RMSE)は、特にイベント期で Tobit が圧倒的に優位 になる。

次回(第 3 回)では、この Tobit モデルの内部で何が起きているのかを、数学的に掘り下げます。具体的には次の問いに答えます。

  • なぜ尤度関数に「密度関数」と「上側生存確率」という 2 種類の項が登場するのか?
  • 潜在変数(latent variable)という考え方はどういうものか?
  • Inverse Mills Ratio という補正項の正体は何か?
  • Sample selection(Heckman 2 段階法[3:1])は Tobit とどう違うのか?

次回は数式が本格的に登場し、Tobit 内部の動作原理を導きます。第 3 回が終われば、第 4・5 回の実装でその理論が動くコードになります。

SALT2 では一緒に働く仲間を募集しています

SALT2 では、本記事で扱った Tobit モデルのような計量経済学・機械学習の手法を実プロジェクトに落とし込み、論文 → 実装 → 意思決定までを一気通貫で扱える環境で働きたいエンジニア/データサイエンティストの方の採用・インターンを継続的に行っています。最新の AI・LLM 論文の輪読や、現場で得た開発ノウハウの共有が日常的に行われる環境です。ご興味のある方は SALT2 の公式サイト からご覧ください。

脚注
  1. Tobin, J. (1958). "Estimation of Relationships for Limited Dependent Variables," Econometrica, 26(1), 24–36. https://doi.org/10.2307/1907382 ↩︎ ↩︎ ↩︎

  2. Breen, R. (1996). Regression Models: Censored, Sample-Selected, or Truncated Data. Sage Publications. https://doi.org/10.4135/9781412985611 ↩︎ ↩︎

  3. Heckman, J. J. (1979). "Sample Selection Bias as a Specification Error," Econometrica, 47(1), 153–161. https://doi.org/10.2307/1912352 ↩︎ ↩︎

  4. Olsen, R. J. (1978). "Note on the Uniqueness of the Maximum Likelihood Estimator for the Tobit Model," Econometrica, 46(5), 1211–1215. https://doi.org/10.2307/1911447 ↩︎

  5. Pedregal, D. J., Trapero, J. R., & Holgado, E. (2024). "Tobit Exponential Smoothing, towards an enhanced demand planning in the presence of censored data." arXiv:2407.17920。 https://arxiv.org/abs/2407.17920 ↩︎

SALT2テックブログ

Discussion