This is an R Markdown document. Markdown is a simple formatting syntax for authoring HTML, PDF, and MS Word documents. For more details on using R Markdown see http://rmarkdown.rstudio.com.
This R markdown document implements a four(4) step procedure for post-hoc test/control difference-in-difference analysis using “coarsened exact matching”. The code implements a simplified version of the method outlined in https://gking.harvard.edu/publications/cem-software-coarsened-exact-matchings.
The R package “MatchIt”, https://cran.r-project.org/web//packages//MatchIt/vignettes/MatchIt.html, also has an option to implement the full version of coarsened exact match, which outputs observation weights on the control arm for use in a regression. This is less transparent, however than the sampling based on the multinomial distribution.
library(magrittr)
# Data Load/cleaning
Data_initial <- read.csv('C:/Users/rechardk/OneDrive - Bristol Myers Squibb/Documents/CE3/Sotyktu_cluster_data_202407_2.csv') %>%
dplyr::select(clst_cd,month, sales, hcp_veeva, calls, ce3_calls, suggested) %>%
dplyr::mutate(Account.id = clst_cd)
BIA_Sotyktu <- read.csv('C:/Users/rechardk/OneDrive - Bristol Myers Squibb/Documents/CE3/BMS-ZS_Encluster_Sotyktu_Otezla_Sales_v1.0.csv') %>%
dplyr::mutate(acc = clst_cd) %>%
dplyr::mutate(date = as.Date(paste0(cald_dt_mth_id,"01"),tryFormats = c("%Y/%m/%d", "%Y%m%d"))) %>%
dplyr::group_by(date) %>%
dplyr::summarise(sales = sum(sales_new, na.rm = TRUE)) %>%
dplyr::ungroup()
head(Data_initial %>% dplyr::select(-Account.id))
## clst_cd month sales hcp_veeva calls ce3_calls suggested
## 1 1.02011e+11 2023-01-01 83127 0016F00003N4eMlQAJ 0 0 0
## 2 1.02011e+11 2023-01-01 83127 0016F00003N5CnXQAV 0 0 0
## 3 1.02011e+11 2023-01-01 83127 0016F00003N5x3OQAR 0 0 0
## 4 1.02011e+11 2023-01-01 83127 0016F00003N5BP5QAN 0 0 0
## 5 1.02011e+11 2023-01-01 83127 0016F00003LxeO1QAJ 0 0 0
## 6 1.02011e+11 2023-01-01 83127 0016F00003N5dzdQAB 0 0 0
CE3 launched in Japan for Sotyktu in November 2023. We consider the 7 months before November 2023 to be the pre-CE3 period, which we cluster level sales to the 7 months after the launch of CE3 (“post-CE3). Each cluster (account) will thus have two rows (pre- and post-CE3), identified by the dummy variable”Time”, with “0” for pre-CE3 and “1” for post-CE3.
The delineation between the “test” and “control” accounts will be based on the percentage of calls that are categorized as executed CE3 calls in the post-CE3 period, so “ce3_percentage” variable is created as well.
This cleaning is shown below:
Data <- Data_initial %>%
dplyr::distinct(Account.id, month, sales, .keep_all = TRUE) %>%
dplyr::filter(month>="2023-04-01") %>%
dplyr::filter(month<="2024-05-01") %>%
dplyr::mutate(Time = ifelse(month<"2023-11-01", 0, 1)) %>%
dplyr::group_by(Account.id, Time) %>%
dplyr::summarise(Sales = sum(sales, na.rm = TRUE),
calls = sum(calls, na.rm = TRUE),
ce3_calls = sum(ce3_calls, na.rm = TRUE),
suggested = sum(suggested, na.rm =TRUE)) %>%
dplyr::mutate(ce3_percentage = ifelse(calls>0,ce3_calls/calls, 0)) %>%
dplyr::ungroup()
## `summarise()` has grouped output by 'Account.id'. You can override using the
## `.groups` argument.
head(Data)
## # A tibble: 6 × 7
## Account.id Time Sales calls ce3_calls suggested ce3_percentage
## <dbl> <dbl> <dbl> <int> <int> <int> <dbl>
## 1 102011001791 0 831270 2 0 0 0
## 2 102011001791 1 1828794 2 1 4 0.5
## 3 102011001793 1 83127 0 0 0 0
## 4 102012000021 0 47501. 3 0 0 0
## 5 102012000021 1 35626. 2 0 0 0
## 6 102021001774 1 665016 16 8 18 0.5
Percentile thresholds for eliminating accounts larger than the XXth percentile (I chose the 95th percentile, but the 99th would also be a good default choice) in both volume and sales growth.
#Parameters
pre_group_outlier_percentile = 0.95
post_group_outlier_percentile = 0.95
sales_growth_percentile_cutoff = 0.95
Outliers_pre <- Data %>%
dplyr::filter(Time==0) %>%
dplyr::mutate(Sales_quantile_control = quantile(Sales, probs = pre_group_outlier_percentile, na.rm =TRUE)) %>%
dplyr::filter(Sales>Sales_quantile_control) %>%
dplyr::select(Account.id)
Outliers_post <- Data %>%
dplyr::filter(Time==1) %>%
dplyr::mutate(Sales_quantile_test = quantile(Sales, probs = post_group_outlier_percentile, na.rm =TRUE)) %>%
dplyr::filter(Sales>Sales_quantile_test) %>%
dplyr::select(Account.id)
Data1a <- Data %>%
dplyr::filter(!(Account.id %in% c(Outliers_pre$Account.id))) %>%
dplyr::filter(!(Account.id %in% c(Outliers_post$Account.id)))
####
# Remove Sales Growth Outliers
sales_growth <- Data1a %>%
dplyr::group_by(Account.id) %>%
dplyr::mutate(sales_total = sum(Sales, na.rm = TRUE),
sales_growth = ifelse(Sales>0,abs(((sales_total - Sales)/Sales-1)*100), 0)) %>%
dplyr::filter(Time == 0) %>%
dplyr::ungroup() %>%
dplyr::mutate(sales_growth_percentile = quantile(sales_growth, probs = sales_growth_percentile_cutoff, na.rm = TRUE)
) %>%
dplyr::select(Account.id, sales_growth,sales_growth_percentile) %>%
dplyr::ungroup()
Data1 <- Data1a %>%
dplyr::left_join(sales_growth, by = "Account.id") %>%
dplyr::filter(sales_growth<sales_growth_percentile)
head(Data1)
## # A tibble: 6 × 9
## Account.id Time Sales calls ce3_calls suggested ce3_percentage sales_growth
## <dbl> <dbl> <dbl> <int> <int> <int> <dbl> <dbl>
## 1 1.02e11 0 8.31e5 2 0 0 0 120
## 2 1.02e11 1 1.83e6 2 1 4 0.5 120
## 3 1.02e11 0 4.75e4 3 0 0 0 25
## 4 1.02e11 1 3.56e4 2 0 0 0 25
## 5 1.02e11 0 1.58e6 18 0 0 0 121.
## 6 1.02e11 1 3.49e6 24 16 30 0.667 121.
## # ℹ 1 more variable: sales_growth_percentile <dbl>
# Account Sales Tiering
HML_designation = Data1 %>%
dplyr::filter(Time ==0) %>%
dplyr::mutate(
sales_decile = dplyr::ntile(Sales,10),
activity_decile = dplyr::ntile(calls,10),
sales_tier = dplyr::case_when(
sales_decile > 7 ~ "High",
sales_decile <= 7 & sales_decile > 4 ~ "Medium",
sales_decile <= 4 ~ "Low",
TRUE ~ "other"
),
F2F_tier = dplyr::case_when(
activity_decile > 7 ~ "High",
activity_decile <= 7 & activity_decile > 4 ~ "Medium",
activity_decile <= 4 ~ "Low",
TRUE ~ "other"
)
) %>%
dplyr::select(Account.id, sales_decile,activity_decile,sales_tier, F2F_tier)
##################################################
##################################################
# Join Data
Data2 = Data1 %>%
#dplyr::select(-ce3_percentage) %>%
dplyr::left_join(HML_designation,by = 'Account.id') %>% # Drop Sales tier zero
dplyr::filter(sales_tier!="other")
table(Data2$sales_tier,Data2$F2F_tier)
##
## High Low Medium
## High 96 28 68
## Low 30 135 58
## Medium 65 64 64
Test and control accounts are delineated based on the composition of CE3 calls in the post-CE3 period. We examine the distribution and to determine appropriate. It is “ok” to iterate different choices for the cutoffs here in order to create a somewhat bimodal distribution, but DO NOT choose these cutoffs to minimize the p-value on the lift coefficient in the diff-in-diff regression later on.
Based on the histogram, we choose the following cutoffs:
ce3_percentile_cutoff_lower = 0.22
ce3_percentile_cutoff_upper = 0.75
quantile(Data2 %>% dplyr::select(ce3_percentage), probs = ce3_percentile_cutoff_lower, na.rm = TRUE)
## 22%
## 0
quantile(Data2 %>% dplyr::select(ce3_percentage), probs = ce3_percentile_cutoff_upper, na.rm = TRUE)
## 75%
## 0.35
All accounts below the 22nd percentile of the CE3 composition distribution (0% CE3 call composition in the post-CE3 period) were considered the “control” group. The 25% of accounts greater than the 75th percentile were considered the “Test” group.
Test_control <- Data2 %>%
dplyr::filter(Time==1) %>%
dplyr::mutate(ce3_percentage_percentile_lower = quantile(ce3_percentage, probs = ce3_percentile_cutoff_lower, na.rm = TRUE),
ce3_percentage_percentile_upper = quantile(ce3_percentage, probs = ce3_percentile_cutoff_upper, na.rm = TRUE)) %>%
dplyr::mutate(Test = ifelse(ce3_percentage <= ce3_percentage_percentile_lower,0,1),
Test = ifelse(ce3_percentage > ce3_percentage_percentile_lower & ce3_percentage <= ce3_percentage_percentile_upper,NA,Test)
) %>%
dplyr::select(Account.id,Test, ce3_percentage)
Test_control
## # A tibble: 281 × 3
## Account.id Test ce3_percentage
## <dbl> <dbl> <dbl>
## 1 102011001791 1 0.5
## 2 102012000021 0 0
## 3 102031001821 1 0.667
## 4 102051001807 NA 0.432
## 5 102091001817 1 0.545
## 6 102101001779 NA 0.357
## 7 111051001550 NA 0.312
## 8 111061001592 NA 0.4
## 9 112011001655 1 0.472
## 10 112031001563 0 0
## # ℹ 271 more rows
#join Test/Control
Data2 = Data2 %>%
dplyr::select(-ce3_percentage) %>%
dplyr::left_join(Test_control,by="Account.id")
We do “Coarsened Exact Matching” algorithm in the R MatchIt package as a way to remove bias from unobserved confounders in of Diff-in-Diff. It is necessary to always match the outcome variable (sales) in the test and control group, but also a parsimonious list of other variables that are the most likely to can affect the outcome (F2F interactions are generally the most influential marketing channel, so they are included).
Because the quality of the matches will degrade the more variables the algorithm is forced to match on, it is best to match on the outcome and at most one or two other influential channels in the pre-period. Total F2F calls are generally the best choice, besides our outcome variable Sales, to use to find matching Test and Control accounts in the pre-CE3 period. Other possible confounding market channels (RTE, Emails, etc.) can be included as additional control variables in the diff-in-diff regression.
First, we examine the balance of sales and F2F calls in the pre-CE3 period (without doing any matching):
#Matchit
# Match on Pre-CE3 Sales and total F2F calls
Data_matchit <- Data2 %>%
dplyr::filter(!is.na(Test)) %>%
dplyr::filter(Time==0)
# Pre matching
m.out0 <- MatchIt::matchit(Test ~ Sales +F2F_tier, data = Data_matchit,
method = NULL, distance = "glm")
summary(m.out0)
##
## Call:
## MatchIt::matchit(formula = Test ~ Sales + F2F_tier, data = Data_matchit,
## method = NULL, distance = "glm")
##
## Summary of Balance for All Data:
## Means Treated Means Control Std. Mean Diff. Var. Ratio eCDF Mean
## distance 0.5491 0.5009 0.4135 1.3819 0.1246
## Sales 412178.4436 298196.9066 0.2878 1.3236 0.0876
## F2F_tierHigh 0.2429 0.2540 -0.0259 . 0.0111
## F2F_tierLow 0.4143 0.5397 -0.2546 . 0.1254
## F2F_tierMedium 0.3429 0.2063 0.2876 . 0.1365
## eCDF Max
## distance 0.1984
## Sales 0.1921
## F2F_tierHigh 0.0111
## F2F_tierLow 0.1254
## F2F_tierMedium 0.1365
##
## Sample Sizes:
## Control Treated
## All 63 70
## Matched 63 70
## Unmatched 0 0
## Discarded 0 0
We want the variance ratio of the test and control groups for sales to be as close to 1 as possible. As you can see, the Test and Control groups were actually pretty well balanced prior to doing any matching (this is good luck, but will not always happen).
Now, using the R MatchIt package, we do “Coarsened Exact Matching” - after splitting the program weights the control observations in order to match the first two moments of the test group distribution of (in this case) in the pre-period.
m.out1 <- MatchIt::matchit(Test ~ Sales +F2F_tier, data = Data_matchit, method = "cem", replace =FALSE)
m.out1
## A matchit object
## - method: Coarsened exact matching
## - number of obs.: 133 (original), 113 (matched)
## - target estimand: ATT
## - covariates: Sales, F2F_tier
summary(m.out1)
##
## Call:
## MatchIt::matchit(formula = Test ~ Sales + F2F_tier, data = Data_matchit,
## method = "cem", replace = FALSE)
##
## Summary of Balance for All Data:
## Means Treated Means Control Std. Mean Diff. Var. Ratio eCDF Mean
## Sales 412178.4436 298196.9066 0.2878 1.3236 0.0876
## F2F_tierHigh 0.2429 0.2540 -0.0259 . 0.0111
## F2F_tierLow 0.4143 0.5397 -0.2546 . 0.1254
## F2F_tierMedium 0.3429 0.2063 0.2876 . 0.1365
## eCDF Max
## Sales 0.1921
## F2F_tierHigh 0.0111
## F2F_tierLow 0.1254
## F2F_tierMedium 0.1365
##
## Summary of Balance for Matched Data:
## Means Treated Means Control Std. Mean Diff. Var. Ratio eCDF Mean
## Sales 300554.1062 283024.2946 0.0443 0.8988 0.0312
## F2F_tierHigh 0.2632 0.2632 0.0000 . 0.0000
## F2F_tierLow 0.4561 0.4561 -0.0000 . 0.0000
## F2F_tierMedium 0.2807 0.2807 -0.0000 . 0.0000
## eCDF Max Std. Pair Dist.
## Sales 0.1178 0.1413
## F2F_tierHigh 0.0000 0.0000
## F2F_tierLow 0.0000 0.0000
## F2F_tierMedium 0.0000 0.0000
##
## Sample Sizes:
## Control Treated
## All 63. 70
## Matched (ESS) 40.28 57
## Matched 56. 57
## Unmatched 7. 13
## Discarded 0. 0
You can see the distributions of
plot(m.out1, type = "density", interactive = FALSE,
which.xs = ~Sales +F2F_tier)
m.data <- MatchIt::match.data(m.out1) %>%
dplyr::select(Account.id, weights)
head(m.data)
## # A tibble: 6 × 2
## Account.id weights
## <dbl> <dbl>
## 1 102011001791 1
## 2 102012000021 0.632
## 3 102091001817 1
## 4 112011001655 1
## 5 112031001563 1.11
## 6 112211001596 1.31
############################################
# Final Analysis Data
Data_matched = Data2 %>%
dplyr::left_join(m.data,by= 'Account.id')
#Data3 = test_control %>%
# dplyr::left_join(Data2,by= 'Account.id')
Perform a weighted least squares diff-in-diff regression (the weights on the control observations come from the matching algorithm in the pre-CE3 period).
#regression on matched data
#summary(lm(Sales~test_1 + Time + test_1:Time ,data = Data3))
summary(lm(Sales~Test*Time ,data = Data_matched, weights=weights))
##
## Call:
## lm(formula = Sales ~ Test * Time, data = Data_matched, weights = weights)
##
## Weighted Residuals:
## Min 1Q Median 3Q Max
## -957660 -300554 -134300 281335 2949309
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 283024 75930 3.727 0.000246 ***
## Test 17530 106909 0.164 0.869905
## Time 331562 107381 3.088 0.002274 **
## Test:Time 242418 151193 1.603 0.110275
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 568200 on 222 degrees of freedom
## (382 observations deleted due to missingness)
## Multiple R-squared: 0.159, Adjusted R-squared: 0.1477
## F-statistic: 13.99 on 3 and 222 DF, p-value: 2.174e-08
# Average Treatment effect of the treated
ATT = lm(Sales~Test + Time + Test:Time ,data = Data_matched)$coefficients[4]
# Manually compute DiD estimator (check)
total_1_1 = Data_matched %>% dplyr::filter(Test ==1 & Time ==1) %>% dplyr::select(Sales) %>% sum()
total_1_0 = Data_matched %>% dplyr::filter(Test ==1 & Time ==0) %>% dplyr::select(Sales) %>% sum()
total_0_1 = Data_matched %>% dplyr::filter(Test ==0 & Time ==1) %>% dplyr::select(Sales) %>% sum()
total_0_0 = Data_matched %>% dplyr::filter(Test ==0 & Time ==0) %>% dplyr::select(Sales) %>% sum()
n_11 = Data_matched %>% dplyr::filter(Test ==1 & Time ==1) %>% dplyr::select(Account.id) %>% dplyr::distinct()
n_10 = Data_matched %>% dplyr::filter(Test ==1 & Time ==0) %>% dplyr::select(Account.id) %>% dplyr::distinct()
n_01 = Data_matched %>% dplyr::filter(Test ==0 & Time ==1) %>% dplyr::select(Account.id) %>% dplyr::distinct()
n_00 = Data_matched %>% dplyr::filter(Test ==0 & Time ==0) %>% dplyr::select(Account.id) %>% dplyr::distinct()
ATT_manual = (total_0_0/dim(n_00)[1] - total_0_1/dim(n_01)[1]) - (total_1_0/dim(n_10)[1] - total_1_1/dim(n_11)[1])
ATT_manual
## [1] 240687.5
Compute Average Treatment effect of the Treated
#sales lift percentage of counterfactual test group
counterfactual_per_account_sales = (total_1_0/dim(n_10)[1] + (total_0_1/dim(n_01)[1] - total_0_0/dim(n_00)[1]))
# Test over counterfactual control percentage
as.numeric(ATT/counterfactual_per_account_sales)*100
## [1] 31.52158
Compute two other sales lift percentages - Treatment impact of the treated as percentage of sa sales in the accounts with mapped HCPs (“aligned sales”) and Treatment impact of the treated as as a percentage of total sales.
total_mapped =Data %>% dplyr::filter(Time==1) %>% dplyr::select(Sales) %>% sum()
total_sales =BIA_Sotyktu %>%
dplyr::filter(date>="2023-11-01" & date<="2024-05-01" ) %>%
dplyr::select(sales) %>%
sum()
sum(m.out1$treat) # initial treatment sample size
## [1] 70
summary(m.out1)$nn[9] # effective treatment sample size
## [1] 57
#Lift as a percentage of sales in the 502 clusters
as.numeric(ATT*summary(m.out1)$nn[9]/total_mapped)*100
## [1] 2.510341
#Lift as a percentage of total sales
as.numeric(ATT*summary(m.out1)$nn[9]/total_sales)*100
## [1] 1.059708