最近ちょっと痛い目見たので自分用に勉強してみます。
今回はログのバイアスの話です。
まずかんたんにやってみる
問題にしたいことを、実際にやってみたいと思います。
状況設定
いま、20個のアイテムの中からランダムに10個のアイテムをランキングにして推薦するレコメンドエンジンがあるとします。
次に、その推薦されたアイテムに対して一定の確率でユーザーは興味のあるアイテムをクリックするとします。
ここで一旦、このレコメンドエンジンのnDCGについて考えてみます。 ある程度多くのユーザー数がいることを仮定して、各ユーザーのnDCGの値の平均を計算してみます。 nDCGについてはこの前の記事をご参考ください。 ここではユーザー数を1000人としてシミュレーションを書いてみます。
import pandas as pd import numpy as np from scipy import stats np.random.seed(seed=42) def dcg(gain, k=None): """ calc dcg value """ if k is None: k = gain.shape[0] ret = gain[0] for i in range(1, k): ret += gain[i] / np.log2(i + 1) return ret def ndcg(y, k=None, powered=False) -> float: """ calc nDCG value """ dcg_score = dcg(y, k=k) ideal_sorted_scores = np.sort(y)[::-1] ideal_dcg_score = dcg(ideal_sorted_scores, k=k) if ideal_dcg_score == 0: # 表示されたが1度もクリックされない場合にはnDCGは0 return 0.0 else : return dcg_score / ideal_dcg_score # アルファベット20文字をアイテムプールとする item_list = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'] num_users = 1000 user_log = [] for i in range(num_users): df = pd.DataFrame({'item' : np.random.choice(item_list, 10, replace = False), 'click': np.random.binomial(1,0.2,size=10)}) user_log.append(df) scores = [] for log in user_log: scores.append(ndcg(log['click'])) ndcg_score = sum(scores) / num_users ndcg_score # 0.4969310364380767
まあ、この値の良し悪しはおいておいて、この値は後で確認することになります。
新しいレコメンドエンジンに対してオフラインテストする
さて、今度はこのレコメンドエンジンと同様の処理を行うレコメンドエンジンについて考えます。 もともと、レコメンドエンジンとはいいながらもランダムにアイテムをピックアップしているだけなので、新しく用意するレコメンドエンジンもランダムにアイテムをピックアップするだけです。
普通に考えて、前のレコメンドエンジンと新しいレコメンドエンジン、どっちもランダムにアイテムを提示しているので、個別のユーザーに対しては多少違いはあれど、ある程度のボリュームのユーザーの集団で見た時レコメンドエンジンの性能に差はなさそうですね。
それでは、ここで先にとってあったユーザーのクリックログを使用して、このレコメンドエンジンの性能をオフラインテストによって評価してみたいと思います。
ユーザーがどのアイテムをクリックするかについては、ログ上にクリックされた記録があるものはユーザーが興味があったアイテムでだとわかるので、新しいレコメンドエンジンで提示したとしてもクリックされるだろうと考えられます。 逆にログに残っていないケースについてはユーザーが興味を持ったことが確認できないのでクリックしていないものとして考えます。 要するにこういうことです。
さて、これで新しいレコメンドエンジンでもnDCGの値を計算できる様になりました。実際に計算してみたいと思います。
user_log_new = [] for i in range(num_users): df = pd.DataFrame({'item' : np.random.choice(item_list, 10, replace = False)}) user_log_new.append(df) scores_new = [] for i in range(num_users): df = user_log_new[i] df_ = user_log[i] df = pd.merge(df, df_, on="item", how='left').fillna(0) scores_new.append(ndcg(df['click'])) ndcg_score_new = sum(scores_new) / num_users ndcg_score_new # 0.331659871450936
あれ、さっきnDCG= 0.50近くあったはずのものが、今度はnDCG=0.33しかありません。
たまたま下回ってしまった可能性もあるので、t検定して確認してみます。
stats.ttest_ind(scores, scores_new, equal_var=False) # Ttest_indResult(statistic=12.71863865873347, pvalue=1.148335352312263e-35)
p_value = 1.14e-35なので、文句のつけようがないレベルでnDCG値が下がっているということがわかりました。
種明かし
さて、ここまでは茶番でした。(ちなみにこんな感じのことをまんまやらかしたんでこの記事書いてます)
上の例でなんとなく雰囲気はご理解いただけたかと思いますが、これが今回シミュレーションしたかったログのバイアスの中身になります。
今回のケースでは、ユーザーの行動に基づくログは、ユーザーがそれを使用した瞬間の状況に依存して行われた行動です。 これを新しい仕組みに対してそのまま適用すると、ログが発生した状況とは異なる状況についてシミュレーションすることになるため、新しい仕組みに対して不利な影響が発生してしまうわけなんですね。
何がタチが悪いって、せっかく推薦システムに良い改善を行ったとしても、この評価結果によって本番に出す前に「期待効果なし」という誤った意思決定をしてしまいかねないんですよね。 かといって、オフライン評価せずに毎回オンライン評価だけでシステムの良し悪しを判断するのはコスパが悪すぎるので、やらざるを得ないってのが難しいところです。
使ったコード
今回使用したシミュレーションのコードはこんな感じです。 需要ないとは思いつつ、一応念の為置いときます。
感想
以上、今回やってみた内容でした。 ちゃんと違いが見れてよかったです。
この辺、知ってる人は知ってるし、そういう人からすると当たり前のことなんですが、きちんとシミュレーションしてみないとイマイチ実感わかないなと思ったのでやってみました。
余談ですが、こういうの正式にはなんていうんですかね?わかったらこっそり教えてほしい…
そのままオフラインテストやるとうまくいかないことはわかったので、今度はどうやってバイアスの影響を排除してオフラインテストを実施したらよいかについて勉強していきたいと思います。(やるとは言ってない)