[実装メモ] OpenBanditPipelineで、自分で用意したログを使う

# 概要
OPEを簡単に実行できるPythonライブラリ、「OpenBanditPipeline」の使い方を1つ検討してたので、メモを残します。

https://techblog.zozo.com/entry/openbanditproject

https://github.com/st-tech/zr-obp

上の記事などで紹介される実装では、基本的にOpenBanditDataset（zozoのオープンデータ）かSyntheticBanditDataset（シミュレーション作成したデータ）を使う方法で実装されています。

しかし、私がやりたいことは、「自分で用意した任意のログに対してバンディットのシミュレーションし、OPEを算出したい」という内容です。
そこで、OpenBanditDatasetもSyntheticBanditDatasetも使わずに、自分の定義したデータを既存ポリシーとして利用する方法を検討しました。


# bandit_feedbackを定義する関数
OpenBanditDatasetやSyntheticBanditDatasetでデータを作成すると、「bandit_feedback」という辞書形式のデータが作成されます。このデータを任意に定義することができれば、そのデータセットを使ってその後のフローを実行できると思いました。

任意のログデータをbandit_feedbackに変換するための関数を以下のように定義しました。
```python
from typing import List
import pandas as pd
import numpy as np
from scipy.stats import rankdata
from sklearn.preprocessing import LabelEncoder

def bandit_feedback_definder(
    df:pd.DataFrame,
    action_col:str,
    action_prob_col:str,
    position_col:str,
    reward_col:str,
    context_cols:List[str]=None,
    action_context_cols:List[str]=None,
    n_rounds:int=None,
    n_actions:int=None,
) -> dict:
    if n_rounds == None:
        n_rounds = df.shape[0]
    if n_actions == None:
        n_actions = df[action_col].unique().shape[0]
    if context_cols == None:
        context = np.zeros((df.shape[0], 1))
    if action_context_cols == None:
        action_context = None

    bandit_feedback = {
        "n_rounds":n_rounds,
        "n_actions":n_actions,
        "action":df[[action_col]].apply(LabelEncoder().fit_transform).values.reshape(-1),
        "position":(rankdata(df[position_col].astype(int).values, "dense") - 1).astype(int),
        "pscore":df[action_prob_col].values,
        "reward":df[reward_col].values,
        "context":context,
        "action_context":action_context,

    }
    return bandit_feedback
```


bandit_feedbackの各キーは以下です。
- "n_rounds":レコード数
- "n_actions":アイテム数
- "action":各レコードにおけるレコメンドされたアイテムを格納した配列
- "position":各レコードの表示順。0始まりで登録する。
- "pscore":各レコードでレコメンドされたアイテムの、レコメンド確率を格納した配列
- "reward":各レコードでレコメンドされたアイテムに対する報酬（クリックされた否かなど）を格納した配列
- "context":ユーザ属性を格納した配列
- "action_context":アイテム特徴を格納した配列


引数dfにログデータのデータフレームを指定します。それ以外の引数は、そのデータフレームにおける対象のカラム名を指定します。

## contextなしの定義
dfの形式
|action|position|action_prob|rewards|
|---|---|---|---|
|contents_A|1|0.35|1|
|contents_B|2|0.21|0|
|contents_D|3|0.11|0|
|contents_A|1|0.41|1|
|...|...|...|...|


```python
bandit_feedback = bandit_feedback_definder(
    df, 
    action_col="action", 
    action_prob_col="action_prob", 
    position_col="position", 
    reward_col="rewards"
)
```
## contextありの定義
dfの形式
|action|position|action_prob|rewards|user_context1|user_context2|...|action_context1|...|
|---|---|---|---|---|---|---|---|---|
|contents_A|1|0.35|1|0.98|1.34|...|2.00|...|
|contents_B|2|0.21|0|0.32|1.79|...|1.67|...|
|contents_D|3|0.11|0|0.77|2.66|...|0.10|...|
|contents_A|1|0.41|1|0.67|0.01|...|0.05|...|
|...|...|...|...|...|...|...|...|...|


```python
bandit_feedback = bandit_feedback_definder(
    df, 
    action_col="action", 
    action_prob_col="action_prob", 
    position_col="position", 
    reward_col="rewards",
    context_cols=["user_context1", "user_context2", ...],
    action_context_cols=["action_context1", "action_context2", ...]
)
```

# action_distの定義
action_distは、新しく適用する方のポリシーの情報です。具体的には、各アイテムがそのラウンド（レコード）においてレコメンドされる確率一覧です。

## シミュレーションで求める場合
このライブラリには、ログデータに対して、仮にバンディットアルゴリズムを適用した場合をシミュレーションで求める機能があります。

以下、ε-greedyおよびThompson Samplingをシミュレーションするための設定を関数化しました。

### context-freeなBanditアルゴリズム向けのシミュレーション関数を定義
```python
from obp.policy import EpsilonGreedy
from obp.policy import BernoulliTS

def context_free_simulation(
    bandit_feedback:dict,
    how:str="e", 
    epsilon:float=0.2,
    alpha:int=1,
    beta:int=1,
    seed:int=1234
):

    if how == "e":
        evaluation_policy = EpsilonGreedy(
            n_actions=bandit_feedback["n_actions"],
            len_list = int(max(bandit_feedback["position"]) + 1), 
            epsilon = 0.2,
            random_state=seed
        )
    elif how == "t":
        evaluation_policy = BernoulliTS(
            n_actions=bandit_feedback["n_actions"],
            len_list = int(max(bandit_feedback["position"]) + 1), 
            alpha=alpha,
            beta=beta,
            random_state=seed
        )
    else:
        raise ValueError("'how' must be 'e' as epsilon-greedy or 't' as Thompson-Sampling.")
    
    return evaluation_policy
```

evaluation_policyで定義されている各値は以下です。
- len_list:1レコメンドにおける表示数。
- epsilon:ε-greedyにおける探索率
- alpha:Thompson Samplingにおける初期値
- beta:Thompson Samplingにおける初期値

### ε-greedyをシミュレーションする場合
```python
rom obp.simulator import run_bandit_simulation

action_dist = run_bandit_simulation(
    bandit_feedback=bandit_feedback, 
    policy=evaluation_policy
)
```
action_distには、(レコード数（ラウンド数）, アイテム数, 表示枠数)というshapeのnp.arrayが格納されます。
以下は、アイテム数5、表示枠数3の場合の例です。
```
action_dist = np.array([ [ [0, 0, 1],  
　　　　　　　　　　    　　[1, 0, 0],
　　　　　　　　　　    　　[0, 1, 0],
　　　　　　　　　　    　　[0, 0, 0],
　　　　　　　　　　    　　[0, 0, 0], ] 」第0レコード
　　　　　　　　　　  　　[ [0, 0, 0], 
　　　　　　　　　　    　　[0, 0, 0],
　　　　　　　　　　    　　[1, 0, 0],
　　　　　　　　　　    　　[0, 1, 0],
　　　　　　　　　　    　　[0, 0, 1],  ] 」第1レコード
　　　　　　　　　　   　...
　　　　　　　　　 ] )
```
第0レコードに対するシミュレーション結果は、「表示枠2にアイテム2、表示枠0にアイテム0、表示枠1にアイテム1」をレコメンドするとなっています。

# Off-Policy Estimatesの算出
## IPS
どのデータを正解にするか（bandit_feedback）と、何で評価するか（ope_estimators）を指定します。
```python
from obp.ope import OffPolicyEvaluation, InverseProbabilityWeighting as IPW


ope = OffPolicyEvaluation(
    bandit_feedback=bandit_feedback, 
    ope_estimators=[IPW()]
)
```
### 評価値算出
- π(a|X) = action_dist
- π_b(a|X) = bandit_feedback["action_prob"]（ライブラリ内部でこれを計算）
という定義で計算されます。
```python
estimated_policy_value = ope.estimate_policy_values(action_dist=action_dist)
estimated_policy_value

 -> {'ipw': 0.11583844057885634}
```
## IPS & DR
DRのために、未観測データに対するrewardを予測するモデルを作成し、その予測結果(estimated_rewards)を求めます。
以下は、モデルにロジスティック回帰を利用する場合で実装しています。

```python
from obp.ope import RegressionModel

regression_model = RegressionModel(
    n_actions = bandit_feedback["n_actions"],
    len_list = int(max(bandit_feedback["position"]) + 1), 
    base_model = LogisticRegression(C=100, random_state=12345)
)

estimated_rewards = regression_model.fit_predict(
    context = bandit_feedback["context"],
    action = bandit_feedback["action"],
    reward = bandit_feedback["reward"],
    position = bandit_feedback["positison"],
    random_state = 12345
)
```
その後同様に、どのデータを正解にするか（bandit_feedback）と、何で評価するか（ope_estimators）を指定します。

```python
from obp.ope import OffPolicyEvaluation
from obp.ope import InverseProbabilityWeighting as IPW
from obp.ope import DoublyRobust as DR

ope = OffPolicyEvaluation(
    bandit_feedback=bandit_feedback, 
    ope_estimators=[IPW(), DR()]
)
```
## 評価値算出
- π(a|X) = action_dist
- π_b(a|X) = bandit_feedback["action_prob"]（ライブラリ内部でこれを計算）
- f(X, π(a|X)) = estimated_rewards
という定義で計算されます。
```python
estimated_policy_value = ope.estimate_policy_values(
    action_dist = action_dist,
    estimated_rewards_by_reg_model = estimated_rewards
)
estimated_policy_value

 -> {'ipw': 0.11583844057885634, 'DR':0.134872019445}
```