Pythonで適正在庫シミュレーションソフトを作ってみた【単一SKU用】

2023年10月16日

適正在庫シミュレーションソフトとは?

「在庫日数は一律30日分」

とか

「よく売れる商品は10日分、その他は30日分」

というように決められることが多い安全在庫ですが、本来は需要変動の大きさに応じて商品ごとに決めるべきます。

需要変動の大きさは標準偏差で表します。

そして、このように計算される安全在庫をキープするような適正発注量を算出するための式は次のように表されます。

\begin{align}
&適正発注量 \\
&=在庫補充目標量-現在庫量 \\
&=安全在庫+需要予測在庫-現在庫量 \\
&=\sqrt{N+M}\times標準偏差\times安全係数+(N+M)\times1日あたりの売上平均-現在庫量 \\
&N:発注してから在庫が補充されるまでのリードタイム(日)\\
&M:発注間隔(日)
\end{align}

適正在庫を維持するための発注数の決め方をわかりやすく【定期発注方式の場合】

しかし、

「このように発注量を決めれば在庫は最適化されるよ」

とはいっても、それまで在庫をダブダブに抱えていた担当者には信じてもらえません。

それを過去のデータからシミュレーションを使って証明する道具が適正在庫シミュレーションソフトです。

そして、これをExcelで簡単に作る方法については【無料サンプル付き】適正在庫シミュレーションをエクセルで作る方法で解説しました。

【無料サンプル付き】適正在庫シミュレーションをエクセルで作る方法

本記事ではこれをPythonで実装してみます。

今回は単一SKUのシミュレーションを行うソフトです。

 

需要データをPandasで読み込む

Item_Aについての1月1日から4月30日までの過去の需要データが、下記のようにcsv形式で格納されているとします。

まずは、これをPandasを使って読み込みます。

import pandas as pd
raw_df = pd.read_csv('raw_data.csv')

これでデータフレームraw_dfに読み込まれました。

 

学習用データとテスト用データにわける

全部で120日分のデータがありますが、これを学習用テスト用にわけます。

今回は、最初の45日分のデータを元に安全在庫量在庫補充目標量を決めて、それを使って後半75日間の在庫推移をシミュレーションすることにします。

学習用データをlean_dfに、テスト用データをtest_dfに入れます。

learn_df = raw_df.iloc[:45]
test_df = raw_df.iloc[45:]

また、後々Numpyの関数を使えるようにデータフレームをnumpy.arrayに変換しておきます。

import numpy as np
learn_data = learn_df.to_numpy()
test_data = test_df.to_numpy()

 

前提条件を設定する

適正在庫理論ではいくつかの前提条件を設定しておく必要があるので、次のように設定しておきます。

# 前提条件設定
ld = 3 # リードタイム=3日
oc = 2 # 発注サイクル=2日
so = 0.05 # 許容欠品率=5%
ini_stock = 5000 # 初期在庫数=5000

 

在庫補充目標量を計算する

在庫補充目標量とは、発注する時に

「発注後の在庫数がこの数量になるように発注しよう」

という時の目標となる在庫数量のことで、次のように計算できます。

\begin{align*}
&在庫補充目標量\\
&=安全在庫+需要予測在庫\\
&=\sqrt{N+M}\times標準偏差\times安全係数+(N+M)\times1日あたりの売上平均-現在庫量\\
&N:発注してから在庫が補充されるまでのリードタイム(日)\\
&M:発注間隔(日)
\end{align*}

Numpyの関数を使って次のように計算できます。

import scipy.stats as st
from scipy.stats import norm

av = learn_data[:, 1].mean()
sd = learn_data[:, 1].std(ddof = 1)

safety_stock = sd * np.sqrt(ld + oc) * norm.ppf(1 - so) # 安全在庫の計算
cycle_stock = np.sum(ld + oc) * av # 需要予測在庫の計算
target_stock = safety_stock + cycle_stock # 在庫補充目標数の計算

 

各リストを初期化する

ここまでできれば、後は発注数や在庫数などを75日間に渡って計算していくだけですが、その計算結果を入れるためのリストを作っておきます。

例えば発注は発注サイクル(今回は2日)に従って発注するため、2日に1日は発注数ゼロになりますが、それも含めて75日間分の発注数をリストに格納していくわけです。

ですので、発注数を格納するリストは長さ75のベクトルになります。

同様に倉庫内在庫数を格納するリストや入荷数を格納するリストも、長さ75のベクトルになります。

 

少し面倒なのがバックオーダー数を格納するリストです。

バックオーダーとは、納品リードタイムが3日である場合、3日後に入荷されるまで倉庫外にある在庫のことです。

パイプライン在庫という場合もあります。

今回のようにリードタイムが3日の場合は、2日分のパイプライン在庫があります。

つまり、(リードタイム-1日)分のバックオーダー在庫があるわけです。

これを発注してから1日後のバックオーダー在庫、発注してから2日後のバックオーダー在庫というようにわけて書くとすると、2✕75のリストが必要になります。

つまりこれは行列です。

 

最後に忘れてはいけないのが在庫補充目標量を入れるリストです。

これは75日のシミュレーション期間に渡って同じなので、毎日の値をリストとして持っておく必要はないのですが、今後日によって可変にする可能性があることも考慮して、長さ75のベクトルとしておきます。

 

以上から、次のように各リストを初期化しておきます。

# 各リストの初期化
target_stock_list = np.full(75 , target_stock) # 日々の在庫補充目標数、すべて同じに
current_stock_list = np.zeros(75) # 日々の在庫数、すべてゼロ
order_qty_list = np.zeros(75) # 日々の発注数、すべてゼロ
back_log_matrix = np.zeros((ld - 1, 75)) # 日々のバックオーダー数、すべてゼロ、行列サイズは(リードタイムー1)×シミュレーション日数
receive_qty_list = np.zeros(75) # 日々の入荷数、すべてゼロ

 

倉庫内在庫量を計算する

次に、初期化したリストに計算結果を書き入れていきます。

まずは倉庫内の在庫量です。

これは

$$当日の在庫数=前日の在庫数-当日の出荷数(需要量)+当日の入荷数$$

で計算できます。

例外は初日の在庫数を計算する時です。

この場合は前日の在庫数がありませんので、初期設定した初期在庫数である5,000を前日在庫数として使います。

for i in range(75):
  if i == 0:
    current_stock_list[i] = ini_stock - test_data[i , 1] + receive_qty_list[i] # 初日のみ初期在庫から当日の在庫数を計算
  else:
    current_stock_list[i] = current_stock_list[i - 1] - test_data[i , 1] + receive_qty_list[i] # 前日の在庫数から当日の在庫数を計算

 

適正発注量を計算する

発注量は

$$発注量=在庫補充目標量-当日の在庫数-当日のすべてのバックオーダー数$$

で計算します。

但し、発注サイクルは2日ですので、シミューレーション開始からの日数を2で割って、割り切れた日だけ発注量を計算するようにします。

for i in range(75):
  if i % oc == 0:
    order_qty_list[i] = target_stock - current_stock_list[i] - np.sum(back_log_matrix[: , i]) # 発注日のみ発注数を計算

 

バックオーダー数と入荷数を計算する

発注した数量は次の日と次の次の日にはまだ入荷されず、バックオーダーになります。(リードタイムが3日の場合)

そして発注3日後には入荷されます。

これを一般化すると、発注してから(リードタイム-1日)後まではバックオーダーに、リードタイム後の日には入荷になります。

従って、バックオーダー数と入荷数は次のように計算できます。

for i in range(75):
  for j in range(ld):
    if j < ld - 1:
      back_log_matrix[j , i] = order_qty_list[i-(j+1)] # (リードタイムー1日)前までに発注したバックオーダー数
    else:
      receive_qty_list[i] = order_qty_list[i-(j+1)] # リードタイム後に納入される入荷数

 

日々の在庫数推移をリストに格納する

以上のコードをまとめるとcurrent_stock_listに日々の在庫数推移の計算結果が格納されます。

import numpy as np
import pandas as pd
import scipy.stats as st
from scipy.stats import norm

raw_df = pd.read_csv('raw_data.csv')
raw_df['Date'] = pd.to_datetime(raw_df['Date']) # Object型をDatetime型に変換する

learn_df = raw_df.iloc[:45]
test_df = raw_df.iloc[45:]
learn_data = learn_df.to_numpy()
test_data = test_df.to_numpy()

# 前提条件設定
ld = 3 # リードタイム=3日
oc = 2 # 発注サイクル=2日
so = 0.05 # 許容欠品率=5%
ini_stock = 5000 # 初期在庫数=5000

av = learn_data[:, 1].mean()
sd = learn_data[:, 1].std(ddof = 1)
safety_stock = sd * np.sqrt(ld + oc) * norm.ppf(1 - so) # 安全在庫の計算
cycle_stock = np.sum(ld + oc) * av # 需要予測在庫の計算
target_stock = safety_stock + cycle_stock # 在庫補充目標数の計算

# 各リストの初期化
target_stock_list = np.full(75 , target_stock) # 日々の在庫補充目標数、すべて同じに
current_stock_list = np.zeros(75) # 日々の在庫数、すべてゼロ
order_qty_list = np.zeros(75) # 日々の発注数、すべてゼロ
back_log_matrix = np.zeros((ld - 1, 75)) # 日々のバックオーダー数、すべてゼロ、行列サイズは(リードタイムー1)×シミュレーション日数
receive_qty_list = np.zeros(75) # 日々の入荷数、すべてゼロ

for i in range(75):
  for j in range(ld):
    if j < ld - 1:
      back_log_matrix[j , i] = order_qty_list[i-(j+1)] # (リードタイムー1日)前までに発注したバックオーダー数
    else:
      receive_qty_list[i] = order_qty_list[i-(j+1)] # リードタイム後に納入される入荷数

  if i == 0:
    current_stock_list[i] = ini_stock - test_data[i , 1] + receive_qty_list[i] # 初日のみ初期在庫から当日の在庫数を計算
  else:
    current_stock_list[i] = current_stock_list[i - 1] - test_data[i , 1] + receive_qty_list[i] # 前日の在庫数から当日の在庫数を計算

  if i % oc == 0:
    order_qty_list[i] = target_stock - current_stock_list[i] - np.sum(back_log_matrix[: , i]) # 発注日のみ発注数を計算

current_stock_list

array([4050.        , 3890.        , 3535.        , 5568.12859065,
3871.12859065, 3040.12859065, 1776.12859065, 5026.12859065,
4899.12859065, 6774.12859065, 4760.12859065, 3611.12859065,
1782.12859065, 3064.12859065, 2694.12859065, 6012.12859065,
4879.12859065, 5346.12859065, 3809.12859065, 4161.12859065,
2391.12859065, 4003.12859065, 3653.12859065, 5424.12859065,
3354.12859065, 3674.12859065, 2371.12859065, 4066.12859065,
3058.12859065, 5449.12859065, 4923.12859065, 5724.12859065,
5098.12859065, 4679.12859065, 4059.12859065, 4634.12859065,
3967.12859065, 5085.12859065, 2850.12859065, 4015.12859065,
1942.12859065, 3952.12859065, 2228.12859065, 5103.12859065,
3339.12859065, 4584.12859065, 2416.12859065, 2798.12859065,
2218.12859065, 5584.12859065, 4862.12859065, 6499.12859065,
4208.12859065, 3246.12859065, 1326.12859065, 4022.12859065,
3319.12859065, 5427.12859065, 4445.12859065, 4331.12859065,
3338.12859065, 4896.12859065, 4116.12859065, 5773.12859065,
4560.12859065, 5043.12859065, 3273.12859065, 3591.12859065,
2117.12859065, 5244.12859065, 4859.12859065, 7431.12859065,
5914.12859065, 5253.12859065, 4660.12859065])

 

シミュレーション結果をグラフで表示する

横軸を日付とし、縦軸に在庫数需要数を取ってグラフを描いてみましょう。

在庫数はcurrent_stock_listに、需要数はtest_dataの2列目に入っているため、次のようにコーディングできます。

from matplotlib import pyplot as plt

x = test_data[:, 0]
plt.figure(figsize=(10, 6))
plt.plot(x, current_stock_list, label = 'Stock')
plt.plot(x, test_data[:, 1], label = 'Demand')
plt.legend()
plt.show

 

2つのグラフを別々にして並べて描くには、オブジェクト指向で次のようにコーディングできます。

fig = plt.figure(figsize = (10,8))

ax1 = fig.add_subplot(2, 1, 1)
ax2 = fig.add_subplot(2, 1, 2)

ax1.plot(x, current_stock_list, color = 'darkblue')
ax2.plot(x, test_data[:, 1], color = 'darkorange')

ax1.set_ylabel('Stock')
ax2.set_ylabel('Demand')

plt.show()

 

複数SKUの適正在庫シミュレーション

Pythonを使ったこのやり方では、行列を使うことにより複数SKU用に簡単にアップグレードできます。

行列演算を使った適正在庫シミュレーションをPythonで実装する【複数SKU用】