case-kの備忘録

日々の備忘録です。データ分析とか基盤系に興味あります。

書籍メモ:効果検証入門 3章 傾向スコアを用いた分析

本記事は効果検証入門3章の備忘録となります。

概要

傾向スコアとは

介入グループと非介入グループのデータの性質を近くすることで、介入の効果を明らかにする方法です。介入が割り振られる傾向スコアが同一のユーザの中ではランダムに決めてるに等しいと言う考えロジスティック回帰を用いて介入変数を目的変数として計算する

活用用途について

  • 介入がランダムに行なわれる場合。

傾向スコアは介入を確率で求め類似する性質のものを比較する方法です。しかし、実際の分析時にはメール配信の意思決定にランダム性がないことが考えられます。例えば予測値が一定以上の場合メールが配信されるが、一定以下の場合配信されないなど。この場合、傾向スコアは常に1か0になるためここで紹介した方法での分析はできません。このような場合5章で説明するような回帰不連続デザインと言う方法を利用します。
www.case-k.jp

  • 学習に必要な過去のデータが十分にある場合

介入確率を算出する上で十分な過去データが必要になります。なので一定量のデータがない場合適用するのは難しいです。

傾向スコアマッチング

得られた傾向スコアを利用してサンプル同士をマッチングさせる方法。セレクションバイアスがなくなるようなサンプルのペアそれぞれの効果を算出して平均する方法です。

IPW

傾向スコアをサンプルの重みとして利用する逆確率重み付き推定。傾向スコアをサンプルの重みとして利用して、与えられたデータ全体での介入を受けた場合の結果の期待値と介入を受けなかった場合の期待値を推定します。IPWはグループ間の傾向スコアの偏りを重みで補正します。傾向スコアが高いデータ(P(X)=0.7)は必然的に介入が行なわれたデータ(Z=1)が多く、傾向スコアが低いデータ*1は加入が行なわれなかったデータ(Z=0)が多く含まれます。この時仮に傾向スコア(P(X))と目的変数(Y)に正の相関があった場合、目的変数の値が小さいデータほど介入データは含まれないことになります。それぞれのグループ内で目的変数の期待値を計算すると、介入が行なわれたグループで算出した平均の期待値はより大きくなり、介入が行なわれなかったグループは平均の期待値が小さくなります。IPWはこのような問題に対して重み付きの平均を求めることで対処しています。バイアスのある平均に対して、サンプルサイズ(n)を傾向スコアの逆数(1/傾向スコア)倍することで、バイアスを取り除いたヒストグラムを実現します。

実践

事前準備

事前にデータセットを準備します。

# install package
library("tidyverse")
# read data
email_data <- read_csv("http://www.minethatdata.com/Kevin_Hillstrom_MineThatData_E-MailAnalytics_DataMiningChallenge_2008.03.20.csv")

データ項目を確認してみます。

  • recency:最後の購入からの経過月数
  • history_segment:昨年の購入額の階層
  • history:昨年の購入額
  • mens:昨年に男物の商品を購入しているか
  • womens:昨年に女物の商品を購入しているか
  • zip_code :地区分類コード
  • newbie:過去12ヶ月に新しくユーザになったか
  • channel:昨年に置いてどのチャンネルから購入したか
  • segment:どのメールが配信されたか
  • visit:メールが配信されてから2週間いないにサイトに訪問したか
  • conversion:メールが配信されてから2週間いないに購入したか
  • spend:購入した際の購入額
head(email_data,5)
# A tibble: 5 x 12
  recency history_segment history  mens womens zip_code  newbie channel segment       visit conversion spend
    <dbl> <chr>             <dbl> <dbl>  <dbl> <chr>      <dbl> <chr>   <chr>         <dbl>      <dbl> <dbl>
1      10 2) $100 - $200    142.      1      0 Surburban      0 Phone   Womens E-Mail     0          0     0
2       6 3) $200 - $350    329.      1      1 Rural          1 Web     No E-Mail         0          0     0
3       7 2) $100 - $200    181.      0      1 Surburban      1 Web     Womens E-Mail     0          0     0
4       9 5) $500 - $750    676.      1      0 Rural          1 Web     Mens E-Mail       0          0     0
5       2 1) $0 - $100       45.3     1      0 Urban          0 Web     Womens E-Mail     0          0     0

介入は男性向けメールと女性向けメールがあるので、まず女性向けメールのレコードを取り除きます。
男性向けのメールが配信されたサンプルと配信されなかったサンプルに絞り込みます。(後から色々と検証できるのでログは残しておいた方が良さそうです)

# remove delivery message for women and add columns of delivery message or not
male_df <- email_data %>%
  filter(segment != "Womens E-Mail") %>%
  mutate(treatment= if_else(segment == "Mens E-Mail", 1, 0))

女性に対して配信されたメールが削除されて、施策の介入有無を意味するカラムが追加されていることを確認できます。

head(male_df, n=5)
# A tibble: 5 x 13
  recency history_segment history  mens womens zip_code newbie channel      segment     visit conversion spend treatment
    <dbl> <chr>             <dbl> <dbl>  <dbl> <chr>     <dbl> <chr>        <chr>       <dbl>      <dbl> <dbl>     <dbl>
1       6 3) $200 - $350     329.     1      1 Rural         1 Web          No E-Mail       0          0     0         0
2       9 5) $500 - $750     676.     1      0 Rural         1 Web          Mens E-Mail     0          0     0         1
3       9 5) $500 - $750     675.     1      1 Rural         1 Phone        Mens E-Mail     0          0     0         1
4       2 2) $100 - $200     102.     0      1 Urban         0 Web          Mens E-Mail     1          0     0         1
5       4 3) $200 - $350     241.     0      1 Rural         1 Multichannel No E-Mail       0          0     0         0

バイアスのあるデータを準備します。

biased_data <- male_df %>%
  mutate(obs_rate_c=if_else(
    (history > 300) | (recency < 6) | (channel == "Multichannel"),
    obs_rate_c,1),
    obs_rate_t=if_else(
      (history > 300) | (recency < 6) | (channel == "Multichannel"),
      1, obs_rate_t),
    random_number=runif(n=NROW(male_df))) %>%
  filter((treatment==0 & random_number < obs_rate_c)|
           (treatment==1 & random_number < obs_rate_t))

中身を確認しておく

 head(biased_data,5)
# A tibble: 5 x 16
  recency history_segment history  mens womens zip_code newbie channel segment visit conversion spend treatment obs_rate_c obs_rate_t
    <dbl> <chr>             <dbl> <dbl>  <dbl> <chr>     <dbl> <chr>   <chr>   <dbl>      <dbl> <dbl>     <dbl>      <dbl>      <dbl>
1       6 3) $200 - $350     329.     1      1 Rural         1 Web     No E-M…     0          0     0         0        0.5          1
2       9 5) $500 - $750     676.     1      0 Rural         1 Web     Mens E…     0          0     0         1        0.5          1
3       9 5) $500 - $750     675.     1      1 Rural         1 Phone   Mens E…     0          0     0         1        0.5          1
4       2 2) $100 - $200     102.     0      1 Urban         0 Web     Mens E…     1          0     0         1        0.5          1
5       4 3) $200 - $350     241.     0      1 Rural         1 Multic… No E-M…     0          0     0         0        0.5          1
ps_model

Call:  glm(formula = treatment ~ recency + history + channel, family = binomial, 
    data = biased_data)

Coefficients:
 (Intercept)       recency       history  channelPhone    channelWeb  
   0.9854765    -0.1248497     0.0006195    -0.3093588    -0.2862948  

Degrees of Freedom: 31896 Total (i.e. Null);  31892 Residual
Null Deviance:	    44050 
Residual Deviance: 41990 	AIC: 42000

傾向スコアマッチング

ライブラリの読み込み

install.packages("MatchIt")
library("MatchIt")

傾向スコアを用いたマッチング

m_near <- matchit(formula=treatment ~ recency + history + channel,
                  data=biased_data,
                  method="nearest",
                  replace=TRUE
                  )

結果を確認

m_near

Call: 
matchit(formula = treatment ~ recency + history + channel, data = biased_data, 
    method = "nearest", replace = TRUE)

Sample sizes:
          Control Treated
All         14791   17106
Matched      8223   17106
Unmatched    6568       0
Discarded       0       0

マッチング後のデータ作成

matched_data <- match.data(m_near)

中身を確認

head(matched_data,5)

  recency history_segment history mens womens zip_code newbie      channel     segment visit conversion spend treatment obs_rate_c
1       6  3) $200 - $350  329.08    1      1    Rural      1          Web   No E-Mail     0          0     0         0        0.5
2       9  5) $500 - $750  675.83    1      0    Rural      1          Web Mens E-Mail     0          0     0         1        0.5
3       9  5) $500 - $750  675.07    1      1    Rural      1        Phone Mens E-Mail     0          0     0         1        0.5
4       2  2) $100 - $200  101.64    0      1    Urban      0          Web Mens E-Mail     1          0     0         1        0.5
5       4  3) $200 - $350  241.42    0      1    Rural      1 Multichannel   No E-Mail     0          0     0         0        0.5
  obs_rate_t random_number  distance   weights
1          1     0.1087145 0.5384078 0.4807085
2          1     0.2857189 0.4985455 1.0000000
3          1     0.4806688 0.4926623 1.0000000
4          1     0.3423975 0.6253792 1.0000000
5          1     0.4290973 0.6537643 1.4421256

マッチング後のデータで効果の推定

matchit()では基本的にATTの推定が行なわれます。

  • ATT (Average Treatment effect on Treated)

ATTは介入を受けたサンプルにおける効果となります。そのため平均的な効果を推定した値と結果が異なる可能性が考えられます。

  • ATE ( Average Treatment effect)

これはATTのように介入グループに対してのみマッチングが行なわれるのではなく、非介入グループに対しても同様にマッチングを行います。マッチングは計算時間が長くなります。また、説明変数が多くなると完全に傾向スコアが一致するケースは少ないため類似してるものでペアを作る必要があります。

P値の値は小さく、介入の効果を確認してみると、0.888であることが確認できます。

matched_data <- match.data(m_near)
head(matched_data,5)

PSM_result<-matched_data %>%
  lm(spend~treatment,data=.)%>%
  tidy()
PSM_result

# A tibble: 2 x 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)    0.710     0.179      3.97 0.0000736
2 treatment      0.795     0.218      3.65 0.000262 

IPW

IPWで効果を調べてみます。

ライブラリをインストール

install.packages("WeightIt")
library("WeightIt")

重みの推定

weighting <- weightit(formula=treatment ~ recency + history + channel,
                      data=biased_data,
                      method="ps",
                      estimand="ATE")

確認

weighting
A weightit object
 - method: "ps" (propensity score weighting)
 - number of obs.: 31897
 - sampling weights: none
 - treatment: 2-category
 - estimand: ATE
 - covariates: recency, history, channel

重みつきデータでの効果の推定

IPW_result <- lm(data=biased_data,
                 formula=spend~treatment,
                 weights=weighting$weights) %>%
  tidy()

メール介入で0.569程度の売上向上。(数値が小さすぎるのと書籍と数値が一致してないので間違ってる?)

IPW_result
# A tibble: 2 x 5
  term        estimate std.error statistic  p.value
  <chr>          <dbl>     <dbl>     <dbl>    <dbl>
1 (Intercept)    0.825     0.122      6.78 1.21e-11
2 treatment      0.569     0.173      3.30 9.77e- 4

傾向スコアと回帰分析はどちらを使うべきか

Yの値がどのような仕組みで決まるか知識がある場合は、回帰分析を使い、Yに関するドメイン知識が少ない場合には傾向スコアを用いるのが望ましい。

回帰分析のメリットとデメリットは次の通りです。
メリット

  • 手軽で取り組みやすい

デメリット

傾向スコアのメリットとデメリットは次の通りです。
メリット

デメリット

  • 計算時間
set.seed(1)
train_flag <- sample(NROW(male_df), NROW(male_df)/2, replace = FALSE)
male_df_train<-male_df[train_flag,] %>%
  filter(treatment==0)
mael_df_test<-male_df[-train_flag,]

predict_model <-glm(
  data=male_df_train,
  formula=conversion~recency+history_segment+channel+zip_code,
  family=binomial
)

pred_cv<-predict(predict_model,
                 newdata=male_df_test,
                 type="response")

pred_cv_rank<-percent_rank(pred_cv)
pred_cv_rank

所感

傾向スコアに置いては、モデルの推定確率を元に分類するためモデルの推定精度は重要になるように思いました。
実務で使える機会はあまりなさそうな気がしましたが、介入がランダムに割り当てられる事象があった場合の効果を見るのに使えそうです。

*1:P(X)=0.3