Accel Brain; Console×

広告配信の最適化やECサイトのレコメンドがステークホルダの満足度に貢献しない場合に「折り合いを付ける」ための観点

スポンサーリンク

問題設定:「機械学習的には最適であっても、それがステークホルダの満足度に貢献しない」という形式の矛盾

アドテクノロジーCRMツール、そして人工知能などといったキーワードやバズワードの影響から、深層学習強化学習を採り入れたソフトウェア開発を要求されることは既に珍しいことではなくなっている。とりわけインターネット広告の配信部分やECサイトのレコメンドエンジンなどにおいては、KGIやKPIを定めている限り、開発したコンテンツの効果をメトリクス化して検証することは比較的容易に実践できる。医療分野などのように、比較的失敗の許されない領域に比べれば、広告やEコマースの領域では、より挑戦的に機械学習の知見を採用することが可能だ。

しかし、インターネット広告であれば広告主やメディアが、ECサイトであればマーケターなどが、それぞれステークホルダとして関わってくる。こうした関係者たちの全てが純粋にデータドリブンな配信やレコメンドを期待している訳ではない。キャンペーンやプロモーションに関わる意思決定次第では、配信対象やレコメンド対象の決定に対して、各ステークホルダの「思惑」を反映した「重み付け」が求められる可能性は大いにあり得る。

そうなると、「ステークホルダはこの広告や商品を露出したいのに、エンジンはこれを露出すべきだと推定している」といった形の競合が発生する可能性がある。「機械学習的には最適であっても、それがステークホルダの満足度に貢献しない」という形式矛盾が発生し得る。

問題解決策:配信やレコメンドを単なるベルヌーイ試行であると見立てる

この問題設定に対する問題解決策は、アーキテクチャ・ドライバを前提とした上で、如何に「折り合い」を付けるのかという観点から記述した方が良いだろう。その一つの策として挙げられるのは、配信やレコメンドをベルヌーイ試行との関連から把握するという策だ。

上述したような矛盾が発生し得る場合、配信やレコメンドを単なるベルヌーイ試行であると見立てることには、大きなメリットがあると言える。ベルヌーイ分布を仮定するなら、開発する機械学習による分類や予測の対象となるのは、例えば(一定期間内に)インプレッションが発生したか否か、クリックされたか否か、購入されたか否かなどといった0/1の事象のみとなる。

このように予測や分類のスコープを限定しておくと、実際にクリックや購入などといった「成果」と呼べる事象が発生したか否かの情報機械学習で取り扱うにしても、その情報意味付けにおいては、人間が介在する余地が生まれる。例えばエスプレッソとカプチーノの広告がカフェラテやマキアートの広告よりもクリックの期待値が同程度に高いという予測結果が得られたなら、最終的にはエスプレッソとカプチーノのいずれか一方を配信対象として選択することになる。その選択において、人間による「重み付け」を判断基準にしてしまえば良い。エスプレッソよりもカプチーノの重みが大きければ、最終的にはカプチーノが選ばれることになる。

ここでいう「重み付け」というのは、ステークホルダによっては、人間の感覚に大きく依存しており、非統計学的で、非データサイエンス的で、非科学的な値かもしれない。しかし、この感覚値は機械学習という「オブジェクト」の「責任」を軽くしてくれるという点で機能する。この意味アーキテクチャ設計の観点から観れば、機械学習のコンポーネントと「重み付け」を実行するコンポーネントは疎結合可能な状態で区別されていた方が良い。

一例:Thompson Sampling

ここでは一例として、広告の配信部分やECサイトのレコメンドエンジンにThompson Samplingを実装する場合を挙げる。

ベータ分布のクラス
class BetaDist(object):
    '''
    ベータ分布
    二項分布の共役事前分布
    '''
    # デフォルトのα統計量
    __default_alpha = 1
    # デフォルトのβ統計量
    __default_beta = 1
    # 成功回数
    __success = 0
    # 失敗回数
    __failure = 0

    def __init__(self, default_alpha=1, default_beta=1):
        '''
        初期化

        Args:
            default_alpha:      デフォルトのα統計量
            default_beta:       デフォルトのβ統計量

        Exceptions:
            TypeError:      default_alphaが実数(int or float)ではない場合
            TypeError:      default_betaが実数(int or float)ではない場合
            ValueError:     default_alphaが正の数ではない場合
            ValueError:     default_betaが正の数ではない場合

        '''
        if isinstance(default_alpha, int) is False:
            if isinstance(default_alpha, float) is False:
                raise TypeError()
        if isinstance(default_beta, int) is False:
            if isinstance(default_beta, float) is False:
                raise TypeError()

        if default_alpha <= 0:
            raise ValueError()
        if default_beta <= 0:
            raise ValueError()

        self.__success += 0
        self.__failure += 0
        self.__default_alpha = default_alpha
        self.__default_beta = default_beta

    def observe(self, success, failure):
        '''
        正否や成否など、結果が二分される試行を観測する

        Args:
            success:      成功回数
            failure:      失敗回数

        Exceptions:
            TypeError:      successが実数(int or float)ではない場合
            TypeError:      failureが実数(int or float)ではない場合
            ValueError:     successが正の数ではない場合
            ValueError:     failureが正の数ではない場合
        '''
        if isinstance(success, int) is False:
            if isinstance(success, float) is False:
                raise TypeError()
        if isinstance(failure, int) is False:
            if isinstance(failure, float) is False:
                raise TypeError()

        if success <= 0:
            raise ValueError()
        if failure <= 0:
            raise ValueError()

        self.__success += success
        self.__failure += failure

    def likelihood(self):
        '''
        尤度を計算する

        Returns:
            尤度
        '''
        try:
            likelihood = self.__success / (self.__success + self.__failure)
        except ZeroDivisionError:
            likelihood = 0.0
        return likelihood

    def expected_value(self):
        '''
        期待値を計算する

        Returns:
            期待値
        '''
        alpha = self.__success + self.__default_alpha
        beta = self.__failure + self.__default_beta

        try:
            expected_value = alpha / (alpha + beta)
        except ZeroDivisionError:
            expected_value = 0.0
        return expected_value

    def variance(self):
        '''
        分散を計算する

        Returns:
            分散
        '''
        alpha = self.__success + self.__default_alpha
        beta = self.__failure + self.__default_beta

        try:
            variance = alpha * beta / ((alpha + beta) ** 2) * (alpha + beta + 1)
        except ZeroDivisionError:
            variance = 0.0
        return variance
Thompson Samplingのクラス
class ThompsonSampling(object):
    '''
    Thompson Sampling簡略版
    期待値の高い順にリストアップするまでは良くても、
    実際には広告主やメディアの意向に応じて別の重み付け要因が絡むため、
    実用的な観点から単純に期待値の高い順にリストアップするだけに留めている
    '''

    # ベータ分布の計算オブジェクト
    __beta_dist_dict = {}

    def __init__(self, arm_id_list):
        '''
        初期化

        Args:
            arm_id_list:    腕のマスタID一覧
        '''
        [self.__beta_dist_dict.setdefault(key, BetaDist()) for key in arm_id_list]

    def pull(self, arm_id, success, failure):
        '''
        腕を引く

        Args:
            arm_id:     腕のマスタID
            success:    成功回数
            failure:    失敗回数
        '''
        self.__beta_dist_dict[arm_id].observe(success, failure)

    def recommend(self, limit=10):
        '''
        期待値の高い順に腕をリストアップする

        Args:
            limit:      リストアップ数の上限値

        Returns:
            (腕のマスタID, 期待値)のtupleのリスト
        '''
        expected_list = [(arm_id, beta_dist.expected_value()) for arm_id, beta_dist in self.__beta_dist_dict.items()]
        expected_list = sorted(expected_list, key=lambda x: x[1], reverse=True)
        return expected_list[:limit]
「重み付け」の関数
def weighting(arm_id_weight_dict, expected_list):
    '''
    重み付けする

    Args:
        arm_id_weight_dict:     マスタID => 重み の辞書
        expected_list:          (マスタID, 期待値)のtupleのリスト

    Returns:
        (腕のマスタID, 重み × 期待値)のtupleのリスト
    '''
    weighted_list = []
    for arm_id, expected_value in expected_list:
        weighted_list.append(
            (arm_id, expected_value * arm_id_weight_dict[arm_id])
        )
    weighted_list = sorted(weighted_list, key=lambda x: x[1], reverse=True)
    return weighted_list
ユースケース
if __name__ == "__main__":
    # 例えば広告のマスタIDとその重み付けのリスト
    arm_id_weight_dict = {
        "ad0001": 1,
        "ad0002": 6,
        "ad0003": 6,
        "ad0004": 4,
        "ad0005": 1
    }

    arm_id_list = [arm_id for arm_id, weight in arm_id_weight_dict.items()]
    # Thompson Samplingのオブジェクト
    # 例えば広告のマスタIDのリストを予め登録しておく
    ts = ThompsonSampling(arm_id_list=arm_id_list)

    click_list = [
        # 例えば広告マスタID、(一定時間内の)クリック数、(一定時間内の)非クリック数
        ("ad0001", 50, 50),
        ("ad0002", 88, 50),
        ("ad0003", 23, 43),
        ("ad0004", 5, 20),
        ("ad0005", 90, 70)
    ]
    # クリック状況をシミュレートするかのごとく、腕を引く
    [ts.pull(arm_id, success, failure) for arm_id, success, failure in click_list]

    # 期待値の高い順にソートした広告マスタID一覧
    expected_list = ts.recommend()

    print("期待値の分析結果")
    for expected_tuple in expected_list:
        print(expected_tuple)

    # 重み付けを付加した結果を取得する
    weighted_list = weighting(arm_id_weight_dict, expected_list)
    print("重み付けした結果")
    for weighted_tuple in weighted_list:
        print(weighted_tuple)

    '''
    期待値の分析結果
    ('ad0002', 0.6357142857142857)
    ('ad0005', 0.5617283950617284)
    ('ad0001', 0.5)
    ('ad0003', 0.35294117647058826)
    ('ad0004', 0.2222222222222222)
    重み付けした結果
    ('ad0002', 3.814285714285714)
    ('ad0003', 2.1176470588235294)
    ('ad0004', 0.8888888888888888)
    ('ad0005', 0.5617283950617284)
    ('ad0001', 0.5)
    '''

参考文献

  • Agrawal, S., & Goyal, N. (2012, June). Analysis of Thompson Sampling for the Multi-armed Bandit Problem. In COLT (pp. 39-1).

フォローする