はじめに
これまで離散事象シミュレーションのコードはゼロベースで書くことが多かったんだけど,PythonにSimPyというパッケージがあることを知ったので試してみた.モデルを作る際の基本的な考え方を掴むのに少し手間取ったけど,わかってみるとよくできていると思った.
結論から言うと,これからも使っていこうと思う.ただし,SimPyでシミュレーションモデルを開発するというよりは,Pythonで開発していく際にアドオン的に利用するというようなイメージがおすすめだろう.SimPyは結構歴史のあるパッケージみたいで,ある意味,成熟している(枯れている)とも言えそうだ.その意味でも,派手さはないけど,安心して使えると思われる.
一方,離散事象シミュレーションというと途中経過をアニメーションとして表示する機能やモデル構築を支援するGUIなどを期待したくなるかもしれないが,そうした機能は含まれていない.アニメーションが必要なら自分の好きな外部モジュールを利用すればいいが,モデル構築にGUIを使いたい人には向いていないかもしれない.
自分用の備忘録も兼ねて,何回かに分けて使い方を整理しておこうと思う.なお,これは自分が使いやすいと思う使い方をまとめたものなので,もしかするとSimPyの標準的な使い方とは流儀が異なるかもしれない.それでももし多少でもどなたかの参考になれば幸いだ.
離散事象シミュレーション
離散事象シミュレーションでは,興味の対象となるシステムの状態がなにかしらの事象が起こるのに応じて変化していくと考える.例えば,あるレストランの中の客数に興味があるとすると,それは「来店」と「退店」という2つの事象によって(のみ)増減するというような具合である.
これを実装するためには,対象システムの状態を捉えるモデル,変化のトリガーとなる事象群,そして事象の生起を管理する仕組みが必要になる.特に,この最後の仕組みは,事象とその生起時刻についての情報を時刻順に並べたリスト(イベントカレンダなどと呼ばれる)を用意しておき,その先頭から順に事象を生起させていくような方法を用いる.
イベントカレンダの先頭から事象が取り出されるたびに,まずその生起時刻まで時計を進める.そして,その事象によってシステムの状態がどのように変化するかに対応する処理(来店なら客数を1名増やし,退店なら1名減らす,など)を実行し,次の事象に移るという流れを繰り返して,システムの状態の変化を追跡していく.
SimPyの概要
SimPyの主な構成要素は,「coreモジュールの中にあるEnvironment」,「eventsモジュール」,そして「resources関連のモジュール群」の3つだと考えればよいだろう.また,これらに加えてジェネレータとして実装されるプロセス関数・メソッドが重要な役割を果たす.「resources関連のモジュール群」については次回以降に取り上げることにして,今回はそれ以外の3つについてみていこう.
Environment
Environmentは文字通り,シミュレーションを実行するための環境を提供するものである.典型的には,
env = simpy.Environment()
のようにしてインスタンス化する.シミュレーションモデルを構成する自作のパーツ(例えば,my_component
)があれば,それもあらかじめ
env.my_component = my_component
のようにして,env.
でアクセスできるようにしておくと便利だと思う.
Environment(のインスタンス,環境env
)はイベントカレンダの面倒をみてくれる.env.step()
でカレンダの先頭から1つずつ順に事象を生起させていくことができるが,普通は,env.run()
でまとめて実行することになるだろう.このとき,env.run(until=時刻)
あるいはenv.run(until=事象)
として,ある時刻まであるいはある事象が生起するまでシミュレーションを進めるという指定が可能である.
シミュレーションの現在時刻はenv.now
で参照でき,env.peek()
で次の事象の生起時刻を確認できる.
eventsモジュール
基礎的な事象はEventクラスで実装されている.このクラスのインスタンス(例えば,事象e
)は,
e = simpy.events.Event(env)
e = simpy.Event(env)
e = env.event()
のいずれかで,上で作成した環境env
の中に生成することができる(後ろの2つはショートカット).ただし,生成しただけでは事象e
は環境env
のイベントカレンダには登録されない(し,いつまでたっても生起しない).
事象をイベントカレンダに登録することをトリガーするという.これは,
e.trigger(event)
e.succeed(value=None)
e.fail(exception)
のいずれかで行う.トリガーされた事象は属性triggered
がFalse
からTrue
に変わり,属性ok
と属性value
にも値が入る.trigger(event)
はこれらの属性を別の事象event
と同じ値にセットする.succeed()
は,ok=True
とし,value
にはもし引数として渡された値があればそれを入れる(渡されなければNone
のまま),fail(exception)
は,ok=False
とし,value
には引数として渡された例外を入れる.
事象はコールバック関数のリストcallbacks
をもっていて,環境env
がイベントカレンダから事象e
を取り出してそれを生起させる際には,e.callbacks
に含まれるコールバック関数を順に実行していく.そしてそれらすべてを実行し終えると,事象e
の属性processed
をTrue
にセットする.
このとき,もしe.ok=False
だった場合は,e.value
に指定されている例外を発生させる.なお,例外を発生させずに,コールバック関数の中で自分で処理したい場合は,e.defused=True
としておけばよい.
なお,e.callbacks
に入れることができるのは,事象e
を唯一の引数としたcallablesのみである.例えば,手動でcallback(e)
という関数を追加したいときには,下記のようにすればよい.
e.callbacks.append(callback)
Eventクラスを継承した特殊なクラスもいくつか用意されているが,その中で特によく用いられるのがTimeoutクラスである.これは,
e = simpy.events.Timeout(env, delay, value=None)
e = simpy.Timeout(env, delay, value=None)
e = env.timeout(delay, value=None)
のいずれかで生成でき,生成からdelay
だけ時間が経過した後に処理されるように,自動的にok=True
でトリガーされる.要するに,ある時点からの時間経過を知らせるタイマーのアラームのような事象である.例えば,所定の時間かかる作業が終了したことを告げるシグナルなどのために用いられる.
プロセス関数・メソッド(まとめてプロセス)
離散事象シミュレーションのコーディングスタイルとして,事象に処理をぶら下げるような書き方が考えられる(し,SimPyでも事象にコールバック関数をもたせているから実質的にはそうしていることにもなる)が,プロセス関数・メソッドでは,それとは逆に,処理に事象をぶら下げるような書き方が簡単にできる.
すなわち,プロセスに処理の流れを書いていく際に,ある事象の生起を待つ,あるいは生起する事象やその結果に応じて処理を分岐させる,といったことを直感的に記述することができる.これによって,処理を事象とは別の切り口でモジュール化しやすくなるし,シミュレーションにエージェントを追加することも容易になると思う.
プロセスは,ジェネレータとして実装され,yield文をもつ.プロセスが呼び出されると,yield文まで進み,そこである事象e
をyieldして一旦停止する.このとき,停止したプロセスをこのyield文の先からもう一度実行するという関数がe.callbacks
に追加される.これによって,事象e
が生起する際に(コールバック関数が呼ばれ),このプロセスが再び動き始めるというトリックになっている.
プロセス(仮に,process_func()
としよう)には引数として環境env
を渡しておくと便利だ.そして,次のいずれかによって,環境env
に登録する(後ろの2つはショートカット).
p = simpy.events.Process(env, process_func(env))
p = simpy.Process(env, process_func(env))
p = env.process(process_func(env))
これはプロセスをスタートさせるシグナルを発する事象(Initialize事象)を生成し,トリガーするという処理を行う.なお,この登録処理の戻り値は,Processクラスのインスタンスである.ProcessはEventを継承しているのでこれも特殊な事象であるといえる.すなわち,上のp
を事象として扱うことができる(returnした際にトリガーされたとみなされ,戻り値があればそれがvalue
の値となる).
この事象p
がトリガーされる前にそのinterrupt()
メソッドを呼ぶことで,対応するプロセスprocess_func
を中止することができる.これによって,process_func
がyieldで待っている事象e
のコールバック関数のリストからprocess_func
の再起動処理が削除される.また,プロセスprocess_func
に例外simpy.exceptions.Interrupt(cause)
が投げ込まれるので,プロセス側でそれを受け取って処理することによって中止時の挙動を指定することができる.このinterrupt()
メソッドは事象e
自体には影響を与えない(ので,例外処理後にその事象e
を再度待ち受けしてもよい).
あるプロセスがyieldする事象e
はあらかじめどこか別の場所で生成しておいてもよい.e.value
やe.ok
の値を受け取ってプロセスで利用することもできる(e.ok
の値を利用する場合はe.defused=True
にしておくこと).&
や|
を用いて,複数の事象のAND結合やOR結合を待つこともできる.この場合,戻り値はOrderedDictになる.逆に,複数のプロセスが同一の事象e
を待つこともできる(その場合は,e.callbacks
に登録された順にプロセスが再起動される).
簡単な在庫管理の例
続いて,簡単な具体例をもとに初歩的な使い方をみていこう.なお,この具体例の中でも「resources関連のモジュール群」は使用しない.
今回取り上げるのは,定量発注方式の在庫管理モデルである.同一種類の品物が在庫に保持されていて,そこにランダムに顧客が訪れ,品物を1点ずつ買っていく.在庫管理者は,在庫量(発注済み未入荷のバックオーダがあればそれを含む)がある量(reorder point)以下になった際に新たに所定量(order quantity)の品物を発注する.発注された品物は一定時間(lead time)経過後に在庫に納入される.
先にコードを載せておく.コードの後に解説があるので,それを参照しながら見てみてほしい.
import random
import simpy
class Model():
def __init__(self, env, init):
self.env = env
self.at_hand = init # how many items you have at hand
self.losses = 0 # opportunity losses
self.orders = [] # list of back orders
@property
def total(self):
return sum(self.orders) +self.at_hand
def send_out(self):
if self.at_hand > 0:
self.at_hand -= 1
else:
self.losses += 1
def receive(self):
if len(self.orders) > 0:
self.at_hand += self.orders.pop(0)
def order(self, num): # num = order quantity
self.orders.append(num)
self.env.process(deliverer(self.env)) # activate deliverer
def report(self):
print('[{}] current level: {}, back order: {}, lost sales: {} '.format(round(self.env.now), self.at_hand, self.orders, self.losses))
def customer(env):
while True:
time_to = random.expovariate(1)
yield env.timeout(time_to)
env.model.send_out()
env.stocktake.succeed() # signal for stocktaking (event)
def manager(env):
env.stocktake = env.event() # create the first signal (event)
while True:
yield env.stocktake
env.model.report()
if env.model.total <= 10: # reorder point = 10
env.model.order(20) # order quantity = 20
env.stocktake = env.event() # create the next signal (event)
def deliverer(env):
yield env.timeout(10) # delivery lead time = 10
env.model.receive()
def main():
env = simpy.Environment()
env.model = Model(env, 10)
env.process(manager(env))
env.process(customer(env))
env.run(until=200)
if __name__ == "__main__":
main()
最初にModelクラスをざっと見ていこう.これは,対象システムのモデルで,SimPyとはほぼ無関係である.在庫量をat_hand
に,在庫切れによって販売機会損失が生じた回数をlosses
に,バックオーダのリストをorders
にそれぞれ格納している.また,total
はバックオーダを含む品物の総数をプロパティとして返す.
send_out()
は出荷処理に対応しており,在庫が空でない場合に1個出荷(at_hand
を1減らす)し,空の場合には機会損失数を1増加させている.receive()
は入庫処理で,orders
のリストの先頭にある発注量をat_hand
に加えている.order()
は発注処理で,orders
のリストの末尾に新しい発注量を追加している.最後のreport()
は,その時点でのシステムの状態をコンソールに表示するメソッドである.
これらの後に記述されている3つの関数,customer()
,manager()
,deliverer()
がプロセス関数である.それぞれ,顧客,在庫管理者,配送業者に対応すると考えてほしい.
顧客プロセスは,指数分布に従う乱数で到着間隔を指定し,それに対応する長さのTimeout事象を待つことで時間を経過させている.その後,出荷処理,状態表示のメソッドを呼び出した後,棚卸しの事象をトリガーしている.なお,この事象は在庫管理者プロセスの中で先に生成されていたものである.while True:
でこの流れが永遠に繰り返されるようになっていることがわかる.
在庫管理者プロセスは,最初に上記の棚卸し事象を生成した後,同様に,while True:
の無限ループが用いられている.ループの中では,棚卸し事象を待ち,その結果total
が10未満であれば20個の発注処理のメソッドと状態表示のメソッドを呼び出していることがわかる.ループの最後に,次の棚卸し事象を生成していることにも注意しよう,
なお,これら2つのプロセスはmain関数の中で環境env
に登録されている.
配送業者プロセスは,長さ10のTimeout事象を待った後,入庫処理のメソッドを呼び出して終了するプロセスである.このプロセスの登録は,Modelのorder()
メソッドの中で行われている.発注処理のたびにそれに対応する配送業者が手配されるというイメージである.
#まとめ
今回はSimPyの基本的な機能のうち「resources関連のモジュール群」以外の部分をまとめてみた.簡単な具体例も紹介したので,これで「はじめの一歩」を踏み出すところまではなんとか進めるのではないかと思う.次回以降は,「resources関連のモジュール群」の使い方,簡単なアニメーションの方法,エージェントの導入例,などについてみていきたい.