CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

離散シミュレーションを活用してブログ・実験に使えるデータを作る方法を考えてみました。

こんにちは、技術開発ユニットの三浦です。

最近は電子書籍を利用することが増えてきました。暗い場所でも読むことが出来たり、たくさんの本を持ち歩くことが出来るのでとても便利です。一方で紙の本にも良さがあって、その1つがどこまで読んだのかをページの厚さで感覚的に把握できる点だと思っています。今日はこれだけ読むことが出来たぞ、という達成感を、読んだページの厚さで感じることが出来ます。

もし電子書籍でこの感じを味わうことが出来たら、すごく良いだろうなぁと思います。

さて、ブログを書く時や新しい技術を試す時に実験用のデータを用意することがあるのですが、権利の問題がないかなど、公開されているものであっても気を使う必要がありますし、必ずしも自分が欲しいフォーマットのデータが見つかるとも限りません。

それならば自分で生成しようと思うのですが、単純に乱数を生成させたとしても、現実のデータとはかけ離れたものになってしまい、結果も説得力に欠けるものになります。

そこで離散シミュレーションを使って想定シナリオに沿った、より現実に近いデータを生成できないか試してみました。今回はPythonの離散シミュレーション用のライブラリSimpyを使って試しています。Pythonのversionは3.9.6です。

Simpyのインストール

SimpyはPythonで離散シミュレーションを実現するためのライブラリです。

simpy.readthedocs.io

Simpyのインストールはpipで行うことが出来ます。

pip install simpy

試しに購入金額1,000円の購買イベントが計10回発生した状況をシミュレーションして、トータルの購入金額をSimpyを使って計算してみます。

import simpy
from collections import namedtuple

#結果保存用
Result = namedtuple('Result','total_amount')
result = Result([0])

#実行されるprocessはgeneratorで定義
def purchase(env):
    result.total_amount[0] +=1000 
    yield env.timeout(1)

#Environmentの作成
env = simpy.Environment()

#10回のprocessを登録
for i in range(10):
    env.process(purchase(env))

#シミュレーション実行
env.run()
print(result.total_amount[0])

結果

10000

トータル金額は間違っていないようです。

以降、Simpyを使って実際にシミュレーションを行っていきますが、Simpyのイベント周りの挙動が現時点でまだ理解しきれておらず、今回の記事に掲載するコードは「とりあえず動かせた」というレベルに留まっています。その点、ご了承ください。

シミュレーションのシナリオ

とあるエリアにある、架空のお店の30日間の売上金額をシミュレーションによって生成してみます。 シミュレーションのシナリオは以下のようにしました。

  • このエリアは毎日80%の確率で晴れ、20%の確率で雨が降ります。
  • このお店は毎日95%の確率で開店し、4%の確率で時短営業、1%の確率で閉店します。
  • このエリアには1,000人のお店の顧客が住んでいます。 顧客はとあるルール(例えば性別、年代など)により、AとBのセグメントに分かれています。 Aは全体の60%、Bは40%を占めています。
  • Aセグメントの顧客は毎日40%の確率で来店し、来店すると平均1,200円の買い物をします。 Bセグメントの顧客は毎日35%の確率で来店し、来店すると平均1,300円の買い物をします。 購入金額はポアソン分布に従います。

f:id:miu4930:20220313190501p:plain
シナリオの登場人物

さらに顧客の来店確率はその日の天気、お店の状態、1週間に1回発生する休日により増減します。

  • 雨の日は来店確率が70%に低下します。
  • 休日はさらに来店確率が150%に増加します。
  • お店が時短営業の日はさらに来店確率が60%に低下します。お店が閉店の時は来店確率は0%になります。

天気・お店の状態・休日の発生

まず顧客の来店に影響を与える、日々の天気・お店の状態と7日周期で訪れる休日の発生をシミュレーションしてみます。 顧客の来店はまだ実装していないので、売上金額グラフのX軸のラベルだけ生成される状態です。

f:id:miu4930:20220313190933p:plain
x軸を生成出来る状態

作成したコードは以下のようになりました。

import simpy
import numpy as np
from collections import namedtuple

#シミュレーションする日数
DAY_NUM = 30

#weather setting
Weather_Setting = namedtuple('Weather_Setting','type, prob')
weather_setting = Weather_Setting(
    ['clear','rainy'],
    [0.8, 0.2]
)

#store setting
Store_Setting = namedtuple('Store_Setting','type, prob')
store_setting = Store_Setting(
    ['open', 'shot-time', 'close'],
    [0.95, 0.04, 0.01]
)

#simulation results
#この変数に各processの結果が保存されます。
Results = namedtuple('Results','weather, store, holiday')

#毎日実行するprocess
def generate_daily_action(env, results):
    while True:
        #天気
        weather = np.random.choice(weather_setting.type, p=weather_setting.prob)
        results.weather.append(weather)
        
        #店舗
        store = np.random.choice(store_setting.type, p=store_setting.prob)
        results.store.append(store)

        #現在のstep求める
        now_date = env.now
        #休日かの判定(0stepは平日の想定)
        holiday_flg = 'W' if (now_date + 1) % 7 else 'H'
        results.holiday.append(holiday_flg)

        #time stepを進める
        yield env.timeout(1)

def main():

    results = Results([],[],[])

    env = simpy.Environment()
    g = generate_daily_action(env, results)
    env.process(g)
    env.run(until=DAY_NUM)

    return results

results = main()

顧客クラスの定義

次に日々の状況に応じて行動を取る、顧客(Customer)クラスを定義します。このクラスはコンストラクタにセグメントタイプ(seg_type)を取り、それに応じてベースの来店確率(base_visit_prob)と平均購入金額(base_amount_avg)をセットします。

actionメソッドが毎日実行されるメソッドで、天気などの状況に応じて変化した来店確率により来店の有無を決定します。来店した場合にはpurchaseメソッドを実行し、ポアソン分布にしたがって購買金額を決定します。来店しなかった場合は購買金額を0にセットします。日々の購買金額はリストpurchase_amountsに保持するようにしました。

class Customer():
    def __init__(self, seg_type):
        self.seg_type = seg_type
        self.base_visit_prob = 0.4 if self.seg_type == 'A' else 0.35
        self.base_amount_avg = 1200 if self.seg_type == 'A' else 1300

        #日別の購入金額を格納する
        self.purchase_amounts = []

    def action(self, weather, holiday_flg, store_status):

        visit_prob = self.base_visit_prob
        if weather == 'rain':
            visit_prob *= 0.7
        
        if holiday_flg == 'H':
            visit_prob *= 1.5
        
        if store_status == 'short':
            visit_prob *= 0.6
        elif store_status == 'close':
            visit_prob *= 0
        else:
            visit_prob = visit_prob
        
        #お店に来店するか
        if np.random.random() < visit_prob:
            self.purchase_amounts.append(self.purchase())
        else:
            self.purchase_amounts.append(0)

    def purchase(self):
        return int(np.random.poisson(self.base_amount_avg))

シミュレーションを実行する

それでは以上で定義したシナリオに従ってシミュレーションを実行し、30日分のお店の売上金額を生成します。結果は以下のようになりました。

f:id:miu4930:20220313191236p:plain
日別のお店の売上金額

雨(rain)や時短(short)には売上金額が落ちていること、そもそもお店が閉店(close)している日は売上金額が0になること、平日(W)よりも休日(H)の方が売上金額が高くなることが確認できます。これらは来店確率の増減によってもたらされる結果です。

また、顧客単位での日別の購入金額も確認することが出来ます。1,000人の顧客からランダムで1人取り出して日別の購入金額を可視化してみました。こちらはあまり変動が見られません。実際には家にあるストック状況や曜日に応じてもっと変動が見られそうですが、今回のシミュレーションで想定したシナリオではそれを表現することは出来ません。

f:id:miu4930:20220313191455p:plain
ある顧客の日別の購入金額

シミュレーション実行用のコードは以下のようになります。

import simpy
import numpy as np
from collections import namedtuple

class Customer():
    def __init__(self, seg_type):
        self.seg_type = seg_type
        self.base_visit_prob = 0.4 if self.seg_type == 'A' else 0.35
        self.base_amount_avg = 1200 if self.seg_type == 'A' else 1300

        #日別の購入金額を格納する
        self.purchase_amounts = []

    def action(self, weather, holiday_flg, store_status):

        visit_prob = self.base_visit_prob
        if weather == 'rain':
            visit_prob *= 0.7
        
        if holiday_flg == 'H':
            visit_prob *= 1.5
        
        if store_status == 'short':
            visit_prob *= 0.6
        elif store_status == 'close':
            visit_prob *= 0
        else:
            visit_prob = visit_prob
        
        #お店に来店するか
        if np.random.random() < visit_prob:
            self.purchase_amounts.append(self.purchase())
        else:
            self.purchase_amounts.append(0)

    def purchase(self):
        return int(np.random.poisson(self.base_amount_avg))


env = simpy.Environment()

#weather setting
Weather_Setting = namedtuple('Weather_Setting','type, prob')
weather_setting = Weather_Setting(
    ['clear','rainy'],
    [0.8, 0.2]
)

#store setting
Store_Setting = namedtuple('Store_Setting','type, prob')
store_setting = Store_Setting(
    ['open', 'short', 'close'],
    [0.95, 0.04, 0.01]
)

#customer setting
total_customers = 1000
segment_A_customers = 600
customer_setting = ['A' for _ in range(segment_A_customers)] \
                    + ['B' for _ in range(total_customers - segment_A_customers)]
customer_list = []
for c in customer_setting:
    customer_list.append(Customer(c))

#simulation results
Results = namedtuple('Results','weather, store, holiday, purchase_amount')

def generate_daily_action(env, results):
    while True:
        #天気
        weather = np.random.choice(weather_setting.type, p=weather_setting.prob)
        results.weather.append(weather)
        
        #店舗
        store_status = np.random.choice(store_setting.type, p=store_setting.prob)
        results.store.append(store_status)

        #現在のstep求める
        now_date = env.now
        #休日かの判定(0stepは平日の想定)
        holiday_flg = 'W' if (now_date + 1) % 7 else 'H'
        results.holiday.append(holiday_flg)
        
        setting = {
            'weather':weather,
            'holiday_flg':holiday_flg,
            'store_status':store_status
        }

        [c.action(**setting) for c in customer_list]
        
        #print(customer_list[0].purchase_amounts)
        results.purchase_amount.append(sum([c.purchase_amounts[-1] for c in customer_list]))
        #time stepを進める
        yield env.timeout(1)


def main():

    results = Results([],[],[],[])
    DAY_NUM = 30
    g = generate_daily_action(env, results)
    env.process(g)
    env.run(until=DAY_NUM)

    return results

results = main()

グラフ描画用のコードです。

import plotly.graph_objects as go
DAY_NUM = 30
days = [str(d + 1 )for d in range(DAY_NUM)]
fig = go.Figure()
fig.add_trace(
    go.Bar(
    x = days,
    y = results.purchase_amount
))
fig.update_xaxes(tickmode='array',
                tickvals=days,
                ticktext=[d + '</br></br>' + w + '</br></br>' + s + '</br></br>' + h 
                        for d, w, s, h in zip(days, results.weather, results.store, results.holiday)])

まとめ

今回は離散シミュレーションによって、シナリオに従ってあるお店の30日間の売上データを生成する、ということを試してみました。今回のシナリオは単純なものですが、もっと細かくシナリオを作ることで、より現実に近いデータが生成出来るのではないか、と思います。シナリオには機械学習のモデルを利用するのも良いと思います。

結構色々な活用方法がありそうで、例えば顧客の購買確率を予測するモデルを評価する際、そのモデルをシナリオに組み込んでシミュレーションを実行し、生成されたデータと現実のデータとの乖離を見てモデルの性能を評価する、とか、過去のデータから、特定顧客の来店確率を1%上げる効果があることが分かっている施策を実施した際の、売上金額の増加とそれにかかるコストの比較もシミュレーションによって行うが出来ると考えられます。

データの生成を目的に調べてみましたが、実際に試してみると機械学習のモデルや施策の評価にも活用できる、とても面白いテクニックだと思いました。