ブラックボックス最適化を用いた広告配信最適化#

ブラックボックス最適化を利用して広告配信の最適化をやってみましょう。
広告配信において以下のようなパラメータを調整します。

  • 入札戦略(CPC, CPM, CPA, ROAS)

  • ターゲティング粒度(性別のみ、年代別、詳細デモグラ、行動履歴)

  • 配信デバイス(PC、スマホ、タブレット、全デバイス)

  • 配信時間帯(全日、平日昼、夜間、週末)

  • クリエイティブ形式(バナー、動画、ネイティブ、リッチメディア)

  • 頻度制御(制限なし、日3回、週5回、月10回)

  • オーディエンス戦略(類似、リターゲティング、興味関心、新規)

  • 予算配分

  • 入札額

  • ターゲット年齢幅

ここではチュートリアル用に用意しているシミュレータを使って広告配信の最適化を行います。

このシミュレータは上記のパラメータをインプットとし、

  • CTR (Click Through Rate): クリック率

  • CVR (Conversion Rate): コンバージョン率

  • CPC (Cost Per Click): クリック単価

  • CPA (Cost Per Acquisition): 獲得単価

  • ROAS (Return on Ad Spend): 広告費対効果

を出力します。

このシミュレータは内部で

  • モバイル×行動ターゲティングの相乗効果

  • 動画×リターゲティングの高いコンバージョン

  • 夕方×モバイルの時間帯効果

  • CPA入札×行動ターゲティングの最適化効果 のような相互作用を考慮しています。

まずは簡単にシミュレータを使ってみましょう。

JijZeptToolsでは jijzepttools.blackbox_optimization.demo の中にいくつかデモ用のシミュレータが用意されています。そこから広告配信のシミュレータをインポートして使います。

import jijzepttools.blackbox_optimization.demo.ad_simulator as ad_sim

広告配信シミュレータを使ってみよう#

こちらが設定できないマーケットのパラメータを設定しましょう。

# 市場環境の設定
market = ad_sim.MarketConditions(
    competition_level=0.7,
    market_saturation=0.4,
    seasonal_factor=1.2,
    economic_index=1.1,
)

次に広告配信戦略に対応するパラメータを設定します。このパラメータがシミュレータのインプットとなります。

ad_sim_params = ad_sim.AdCampaignParams(
    bidding_strategy=ad_sim.BiddingStrategy.CPC,
    targeting_granularity=ad_sim.TargetingGranularity.AGE_GROUP,
    device_target=ad_sim.DeviceTarget.ALL_DEVICES,
    time_slot=ad_sim.TimeSlot.ALL_DAY,
    creative_format=ad_sim.CreativeFormat.BANNER,
    frequency_cap=ad_sim.FrequencyCap.DAILY_3,
    audience_strategy=ad_sim.AudienceStrategy.INTEREST,
    budget_allocation=1.0,
    bid_amount=150,
    target_age_range=30,
)

このパラメータをrunnerに渡してシミュレータを実行します。

runner = ad_sim.AdCampaignRunner(market_conditions=market)
result = runner.analyze_campaign(ad_sim_params)
result
{'ctr': 0.01320148047315424,
 'cvr': 0.034780468250565445,
 'cpc': 135.83788652481957,
 'cpa': 3905.5795783488675,
 'roas': 0.025604394429539764,
 'total_score': 0.254137059895093,
 'constraints_satisfied': True,
 'constraint_violations': []}

ではこのシミュレータを利用したブラックボックス関数を作っておきましょう。JijZeptToolsが対応しているブラックボックス関数はdict[str, str | float]を受け取るのでrunnerのインターフェースと合わせるためにEnumへの変換を実装しておく必要があります。

def ad_campaign_blackbox_func(params: dict):
    ad_params = ad_sim.AdCampaignParams(
        bidding_strategy=ad_sim.BiddingStrategy(params['bidding_strategy']),
        targeting_granularity=ad_sim.TargetingGranularity(params['targeting_granularity']),
        device_target=ad_sim.DeviceTarget(params['device_target']),
        time_slot=ad_sim.TimeSlot(params['time_slot']),
        creative_format=ad_sim.CreativeFormat(params['creative_format']),
        frequency_cap=ad_sim.FrequencyCap(params['frequency_cap']),
        audience_strategy=ad_sim.AudienceStrategy(params['audience_strategy']),
        budget_allocation=params['budget_allocation'],
        bid_amount=params['bid_amount'],
        target_age_range=params['target_age_range'],
    )
    return runner.analyze_campaign(ad_params)

ブラックボックス最適化#

決定変数の設定#

JijZeptToolsではブラックボックス最適化を行うためのjijzepttools.blackbox_optimizationモジュールが用意されています。まずBlackBoxkProblemクラスのオブジェクトを作成し、決定変数を設定します。

  • Binary: 0 or 1

  • Categorical: 文字列のリストをcategoryとして設定し、そのうちの一つを選ぶ。

  • Integer: int

  • Continuous: float

の4つの変数を利用することができます。

.add_*メソッドを使って決定変数を追加することができます。また、.add_*メソッドはJijModelingの決定変数オブジェクトを返します。あとからそのオブジェクトを使って制約条件を追加することができます。まずはそういった制約条件の追加は無しで決定変数を設定していきましょう。

from jijzepttools.blackbox_optimization.bbo_ommx import BlackboxProblem
import jijmodeling as jm

bb_model = BlackboxProblem('ad opt', description='広告キャンペーン最適化', sense=jm.ProblemSense.MAXIMIZE)

bidding_strategy = bb_model.add_CategoricalVar('bidding_strategy',
                          ['CPC', 'CPA', 'CPM'])
targeting_granularity = bb_model.add_CategoricalVar('targeting_granularity',
                          ['age_group', 'detailed_demo', 'gender_only', 'behavioral'])
bb_model.add_CategoricalVar('device_target',
                          ['all_devices', 'mobile', 'pc', 'tablet'])
bb_model.add_CategoricalVar('time_slot',
                          ['all_day', 'evening', 'weekday_daytime', 'weekend'])
creative_format = bb_model.add_CategoricalVar('creative_format',
                          ['banner', 'video', 'native', 'rich_media'])
bb_model.add_CategoricalVar('frequency_cap',
                          ['daily_3', 'weekly_5', 'no_limit'])
bb_model.add_CategoricalVar('audience_strategy',
                          ['interest', 'retargeting', 'new_acquisition'])
budget_allocation = bb_model.add_ContinuousVar('budget_allocation', 0.1, 2.0)
bb_model.add_IntegerVar('bid_amount', 50, 500)
bb_model.add_IntegerVar('target_age_range', 10, 50)

bb_model.decision_variables_table()
name type lower_bound upper_bound categories description step
0 bidding_strategy Categorical 0 2 [CPC, CPA, CPM] NaN
1 targeting_granularity Categorical 0 3 [age_group, detailed_demo, gender_only, behavi... NaN
2 device_target Categorical 0 3 [all_devices, mobile, pc, tablet] NaN
3 time_slot Categorical 0 3 [all_day, evening, weekday_daytime, weekend] NaN
4 creative_format Categorical 0 3 [banner, video, native, rich_media] NaN
5 frequency_cap Categorical 0 2 [daily_3, weekly_5, no_limit] NaN
6 audience_strategy Categorical 0 2 [interest, retargeting, new_acquisition] NaN
7 budget_allocation Continuous 0.1 2 NaN NaN
8 bid_amount Integer 50 500 None 1.0
9 target_age_range Integer 10 50 None 1.0

初期データセットなどをセットアップ#

BlackBoxProblemにはランダムなデータを生成するためのrandom_candidatesメソッドがあります。これを使って初期データセットを生成します。初期データセットはブラックボックス最適化の初期値として利用されます。 実際はこの初期データセットは過去の実績データなどを使って生成することが多いですが、ここではランダムに生成します。

# ランダムにサンプルを生成
init_dataset = bb_model.random_candidates(num_samples=4)

# 各サンプルを評価
init_evals = [ad_campaign_blackbox_func(d) for d in init_dataset]

次にBlackboxOptimizationオブジェクトを準備しましょう。 BlackboxOptimizationBlackBoxProblemを受け取ります。BlackBoxProblemは決定変数の設定などモデルに対しての設定を行うクラスなのに対して、BlackboxOptimizationは実際にブラックボックス最適化を行うアルゴリズム側を管理するクラスです。

またBlackboxOptimizationは.runメソッドを呼ぶ前に.setupを読んで初期データセットなどを格納する必要があります。

from jijzepttools.blackbox_optimization.bbo_ommx import BlackboxOptimization

bb_opt = BlackboxOptimization(bb_model)
bb_opt.setup((init_dataset, init_evals), objectives=['total_score'])

Blackbox Optimizationの実行#

.runメソッドを呼ぶことでブラックボックス最適化を実行することができます。 n_iterを指定することで、ブラックボックス最適化の反復回数を指定できます。BlackboxOptimizationのオブジェクトが自らブラックボックス関数を呼び出して最適化を行います。

第三引数でommx.v1.Instanceで構成されるサロゲートモデルを解くためのソルバーを指定することができます。デフォルトではommx_pyscipopt_adapterが指定されており、SCIPを使ってサロゲートモデルを解くことができます。

返り値のnewX, newyには最適化の結果が格納されています。

newX, newy = bb_opt.run(
    n_iter=20,
    blackbox_func=ad_campaign_blackbox_func,
)
/home/runner/work/JijZeptTools/JijZeptTools/.venv/lib/python3.10/site-packages/torch/utils/data/dataset.py:473: UserWarning: Length of split at index 1 is 0. This might result in an empty dataset.
  warnings.warn(
/home/runner/work/JijZeptTools/JijZeptTools/.venv/lib/python3.10/site-packages/ommx_pyscipopt_adapter/adapter.py:30: UserWarning: linked SCIP 9.02 is not recommended for this version of PySCIPOpt - use version 9.2.1
  self.model = pyscipopt.Model()
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[9], line 1
----> 1 newX, newy = bb_opt.run(
      2     n_iter=20,
      3     blackbox_func=ad_campaign_blackbox_func,
      4 )

File ~/work/JijZeptTools/JijZeptTools/jijzepttools/blackbox_optimization/bbo_ommx.py:94, in BlackboxOptimization.run(self, n_iter, blackbox_func, solver)
     92 history_y = []
     93 for iteration in range(n_iter):
---> 94     recommend_list = _multi_obj_fm_optimization(
     95         self, solver, self.fm_trainer, self._instance
     96     )
     97     recommend_list[0] = self._perturbation_exploration(
     98         history_x, recommend_list[0]
     99     )
    100     history_x.append(recommend_list[0])

File ~/work/JijZeptTools/JijZeptTools/jijzepttools/blackbox_optimization/bbo_ommx.py:157, in _multi_obj_fm_optimization(bbopt, solver, fm_trainer, instance)
    155 for obj_index in range(num_obj):
    156     y = bbopt.y[:, obj_index]
--> 157     fm_trainer.fit(bbopt.X, y, n_epochs=100)
    158     fm_objective = create_fm_model(
    159         fm_trainer.model.w0.tolist()[0],
    160         fm_trainer.model.w.tolist()[0],
   (...)
    164         fm_rank=fm_trainer.model.latent_dim,
    165     )
    166     instance.objective = fm_objective

File ~/work/JijZeptTools/JijZeptTools/jijzepttools/blackbox_optimization/factorization_machine.py:95, in FMTrainer.fit(self, x_numpy, y_numpy, n_epochs)
     90 for x_batch, y_batch in train_loader:
     91     # for batch_index in range(x.shape[0]):
     92     #     x_batch = x[batch_index: batch_index+1]
     93     #     y_batch = y[batch_index: batch_index+1]
     94     self.optimizer.zero_grad()
---> 95     y_pred = self.model(x_batch)
     96     loss = func.mse_loss(y_pred, y_batch)
     97     loss.backward()

File ~/work/JijZeptTools/JijZeptTools/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py:1739, in Module._wrapped_call_impl(self, *args, **kwargs)
   1737     return self._compiled_call_impl(*args, **kwargs)  # type: ignore[misc]
   1738 else:
-> 1739     return self._call_impl(*args, **kwargs)

File ~/work/JijZeptTools/JijZeptTools/.venv/lib/python3.10/site-packages/torch/nn/modules/module.py:1750, in Module._call_impl(self, *args, **kwargs)
   1745 # If we don't have any hooks, we want to skip the rest of the logic in
   1746 # this function, and just call forward.
   1747 if not (self._backward_hooks or self._backward_pre_hooks or self._forward_hooks or self._forward_pre_hooks
   1748         or _global_backward_pre_hooks or _global_backward_hooks
   1749         or _global_forward_hooks or _global_forward_pre_hooks):
-> 1750     return forward_call(*args, **kwargs)
   1752 result = None
   1753 called_always_called_hooks = set()

File ~/work/JijZeptTools/JijZeptTools/jijzepttools/blackbox_optimization/factorization_machine.py:41, in FactorizationMachine.forward(self, x)
     38 linear_terms = self.linear(x)
     40 square_of_sum = torch.pow(torch.matmul(x, self.quad), 2)
---> 41 sum_of_square = torch.matmul(x**2, self.quad**2)
     42 quad_terms = 0.5 * torch.sum(square_of_sum - sum_of_square, dim=1, keepdim=True)
     44 return linear_terms + quad_terms

File ~/work/JijZeptTools/JijZeptTools/.venv/lib/python3.10/site-packages/torch/_tensor.py:37, in _handle_torch_function_and_wrap_type_error_to_not_implemented.<locals>.wrapped(*args, **kwargs)
     33 @functools.wraps(f, assigned=assigned)
     34 def wrapped(*args, **kwargs):
     35     try:
     36         # See https://github.com/pytorch/pytorch/issues/75462
---> 37         if has_torch_function(args):
     38             return handle_torch_function(wrapped, args, *args, **kwargs)
     39         return f(*args, **kwargs)

KeyboardInterrupt: 
import matplotlib.pyplot as plt

total_scores = [d['total_score'] for d in newy]
best_scores= [0]
for d in newy:
    best_scores.append(max(best_scores[-1], d['total_score']))
best_scores = best_scores[1:]  # 最初の値を削除

plt.figure(figsize=(10, 5))
plt.title('Total Scores Over Iterations')
plt.xlabel('Iteration')
plt.ylabel('Total Score')
plt.xticks(range(len(total_scores)))
plt.grid(True)
plt.ylim(0, max(total_scores) * 1.1)  # 上限値を設定
plt.plot(total_scores)
plt.plot(best_scores, linestyle='-', color='red', label='Best Score')
plt.legend()
plt.tight_layout()
../_images/e0eb381a030e4efcba1de5fd6cee20e0ea6fc3a7d98ae6c8904a5060b12918e5.png

1度だけ回す#

ブラックボックス最適化においてはブラックボックス関数が実験が必要だったりでコードの中に関数として持ち込めない場合があります。   その場合は.runメソッドのn_iter=1を指定して1度だけ回すことができます。 この時はブラックボックス関数を渡さなくても良いです。

newX, newy = bb_opt.run(
    n_iter=1
)
newX
/Users/yuyamashiro/workspace/JijZeptTools/.venv/lib/python3.11/site-packages/ommx_pyscipopt_adapter/adapter.py:30: UserWarning: linked SCIP 9.02 is not recommended for this version of PySCIPOpt - use version 9.2.1
  self.model = pyscipopt.Model()
[{'bidding_strategy': 'CPC',
  'targeting_granularity': 'gender_only',
  'creative_format': 'native',
  'audience_strategy': 'new_acquisition',
  'bid_amount': 500.0,
  'target_age_range': 10.0,
  'frequency_cap': 'daily_3',
  'time_slot': 'weekday_daytime',
  'budget_allocation': 2.0,
  'device_target': 'mobile'}]
newy # ブラックボックス関数を渡していないので空です。
[]

制約条件を陽に記述する#

この広告は新シミュレータでは実は以下の制約条件があり、これを満たしていない時はスコアが0になります。

  • 動画広告の場合は予算配分を多め (0.8以上) にする必要がある

これを明示的に設定しておきましょう。BlackboxProblem.problemに制約条件を追加してください。

カテゴリ変数はバイナリ変数とOnehot制約で表されています。

また、creative_formatのカテゴリーを['banner', 'video', 'native', 'rich_media']として設定しているのでvideoが設定されているかどうかはcreative_format[1]が1か0かで判断できます。
これを用いて以下のように制約条件を実装します。

bb_model.problem += jm.Constraint(
    "budget constraint",
    0.8*creative_format[1] <= budget_allocation
)

制約条件付きでブラックボックス最適化を実行#

bb_opt = BlackboxOptimization(bb_model)
bb_opt.setup((init_dataset, init_evals), objectives=["total_score"])

newX, newy = bb_opt.run(
    n_iter=20,
    blackbox_func=ad_campaign_blackbox_func,
)
/Users/yuyamashiro/workspace/JijZeptTools/.venv/lib/python3.11/site-packages/torch/utils/data/dataset.py:473: UserWarning: Length of split at index 1 is 0. This might result in an empty dataset.
  warnings.warn(
total_scores = [d["total_score"] for d in newy]
best_scores = [0]
for d in newy:
    best_scores.append(max(best_scores[-1], d["total_score"]))
best_scores = best_scores[1:]  # 最初の値を削除

plt.figure(figsize=(10, 5))
plt.title("Total Scores Over Iterations")
plt.xlabel("Iteration")
plt.ylabel("Total Score")
plt.xticks(range(len(total_scores)))
plt.grid(True)
plt.ylim(0, max(total_scores) * 1.1)  # 上限値を設定
plt.plot(total_scores)
plt.plot(best_scores, linestyle="-", color="red", label="Best Score")
plt.legend()
plt.tight_layout()
../_images/dfafcfb4c91d9155a850ec7bb4e89cc470d44616072f671e40310f6b04f53422.png

制約条件を追加したことでコスト0が出にくくなったことが確認できるはずです。

ブラックボックス最適化とはいえ、自明なもしくはドメイン知識からわかる制約条件を追加しておくことで安定した結果が得られることが多いです。

内部のアルゴリズムの挙動によっては後処理が入るため、制約条件を追加しても制約を破る解に変更されてしまっている場合があります。