Wyatt P. Bensken and Harry Persaud built this example in 2020-2022. Thomas Love converted it to Quarto in 2023, and made some edits. If you notice errors or encounter problems, contact Thomas dot Love at case dot edu.
1 Setup
Code
library(knitr)opts_chunk$set(comment =NA) options(max.print="250")opts_knit$set(width=75)library(janitor) library(broom)library(patchwork)library(cobalt)library(Matching)library(tableone)library(survey)library(survival)library(twang)library(naniar)library(magrittr)library(lme4)library(tidyverse) # load tidyverse last## Note that we will also use the broom.mixed package## but we won't load it heredecim <-function(x, k) format(round(x, k), nsmall=k)theme_set(theme_light()) # set theme for ggplots
Note: we will also use the broom.mixed package, but we are not loading it as to prevent it conflicting with the functions of broom.
2 Load data
Information on the lindner dataset can be found at at this site1.
After reading in the data, we can print the first 10 rows to get a sense of what our data looks like. We see the tibble contains information on 996 participants, and there is no missing data.
3 Data managment
3.1 Managing binary variables
In the course of this example, we’ll want both a numeric and factored version of each binary variable.
In all numeric versions of binary variables: 1 indicates ‘yes’ to having trait/characteristic, 0 indicates ‘no’ to having trait/characteristic.
Variable names with trailing “_f” denotes the factored version of each binary variable.
Code
# Six month survival (turning logical variable to a factor)lindner_raw$sixMonthSurvive_f <-factor(lindner_raw$sixMonthSurvive, levels =c(TRUE,FALSE),labels =c("yes", "no"))# Creating numeric (1/0) version of six month survival variablelindner_raw$sixMonthSurvive <-factor(lindner_raw$sixMonthSurvive_f, levels =c("yes","no"),labels =c(1, 0))lindner_raw$sixMonthSurvive <-ifelse(lindner_raw$sixMonthSurvive =="1", 1, 0)#Add variable named treated (same values as abcix variable)lindner_raw$treated <- lindner_raw$abcix# Factoring the exposure of interest variable. Change the name to 'treated' too.lindner_raw$treated_f <-factor(lindner_raw$abcix, levels =c(1,0),labels =c("treated", "control"))# Factor version of stent variablelindner_raw$stent_f <-factor(lindner_raw$stent, levels =c(1,0),labels =c("yes", "no"))# Factoring the female variablelindner_raw$female_f <-factor(lindner_raw$female, levels =c(1,0),labels =c("female", "male"))# Factoring the diabetic variablelindner_raw$diabetic_f <-factor(lindner_raw$diabetic, levels =c(1,0),labels =c("yes", "no"))# Factoring the acutemi variablelindner_raw$acutemi_f <-factor(lindner_raw$acutemi, levels =c(1,0),labels =c("yes", "no"))# Add patientidlindner_raw <- tibble::rowid_to_column(lindner_raw, "patientid")# Make lindner dataset with "clean" name. lindner_clean <- lindner_raw
3.2 Inspecting the clean data
Code
mosaic::inspect(lindner_clean)
Registered S3 method overwritten by 'mosaic':
method from
fortify.SpatialPolygonsDataFrame ggplot2
Information was copy/pasted from here (with some changes to reflect this analysis)
cardbill (Quantitative Outcome): “Cardiac related costs incurred within 6 months of patient’s initial PCI; numeric value in 1998 dollars; costs were truncated by death for the 26 patients with lifepres == 0.”
sixMonthSurvive/sixMonthSurvive_f (Binary Outcome): “Survival at six months a recoded version of lifepres.”
treated/treated_f (Exposure): “Numeric treatment selection indicator; 0 implies usual PCI care alone; 1 implies usual PCI care deliberately augmented by either planned or rescue treatment with abciximab.”
stent/stent_f: “Coronary stent deployment; numeric, with 1 meaning YES and 0 meaning NO.”
height: “Height in centimeters; numeric integer from 108 to 196.”
female/female_f: “Female gender; numeric, with 1 meaning YES and 0 meaning NO.”
diabetic/diabetic_f: “Diabetes mellitus diagnosis; numeric, with 1 meaning YES and 0 meaning NO.”
acutemi/acutemi_f: “Acute myocardial infarction within the previous 7 days; numeric, with 1 meaning YES and 0 meaning NO.”
ejecfrac: “Left ejection fraction; numeric value from 0 percent to 90 percent.”
ves1proc: “Number of vessels involved in the patient’s initial PCI procedure; numeric integer from 0 to 5.”
patientid: a made-up order of numbers to assign patient IDs for later use
treated_f min Q1 median Q3 max mean sd n missing
1 treated 3563 10902.25 12944 17080.75 96741 16126.68 9383.825 698 0
2 control 2216 8300.00 10423 15895.75 178534 14614.22 14513.996 298 0
Across the entire sample, the mean ($16,127 vs. $14,614) and median ($12,944 vs. $10,423) cardiac care costs were higher in treated individuals than non-treated controls.
Code
ggplot(lindner_clean, aes(x = cardbill, fill ="cardbill")) +geom_histogram(bins =20) +labs(y ="Count",x ="Cardiac care costs ($)",title ="Cardbill appears to be right skewed") +guides(fill ="none")
As we can see in this figure, cardbill appears to be right/positively skewed.
The odds treated individuals were alive after 6 months was roughly 3.31 times the odds that non-treated individuals were alive after 6 months.
Code
unadjust_binary_outcome <-glm(sixMonthSurvive ~ treated, data = lindner_clean, family =binomial())unadjust_binary_outcome_tidy <-tidy(unadjust_binary_outcome, conf.int =TRUE, conf.level =0.95, exponentiate =TRUE) %>%filter(term =="treated")unadjust_binary_outcome_tidy |>kable(digits =3)
term
estimate
std.error
statistic
p.value
conf.low
conf.high
treated
3.31
0.403
2.969
0.003
1.511
7.479
The odds of being alive after six months in treated individuals was 3.31 (95% CI: 1.51, 7.48) times higher than the odds that a non-treated control would be alive after six months.
7 Task 2: Fitting the propensity score model
We will now fit the propensity score, which predicts treatment status based on available covariates. Remember, we’re not worried about overfitting (including too many covariates) when calculating the propensity scores.
Code
psmodel <-glm(treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc, family =binomial(), data =lindner_clean)summary(psmodel)
Call:
glm(formula = treated ~ stent + height + female + diabetic +
acutemi + ejecfrac + ves1proc, family = binomial(), data = lindner_clean)
Coefficients:
Estimate Std. Error z value Pr(>|z|)
(Intercept) 2.965651 1.731085 1.713 0.08668 .
stent 0.573018 0.150454 3.809 0.00014 ***
height -0.015366 0.009534 -1.612 0.10700
female -0.359060 0.206904 -1.735 0.08267 .
diabetic -0.406810 0.170623 -2.384 0.01711 *
acutemi 1.199548 0.270468 4.435 9.20e-06 ***
ejecfrac -0.014789 0.007403 -1.998 0.04574 *
ves1proc 0.760502 0.138437 5.493 3.94e-08 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for binomial family taken to be 1)
Null deviance: 1215.5 on 995 degrees of freedom
Residual deviance: 1124.3 on 988 degrees of freedom
AIC: 1140.3
Number of Fisher Scoring iterations: 4
We also “fail” Rubin’s Rule 2 where we are looking for value between 0.8 - 1.2 (ideally, 1).
9 Task 4: Greedy 1:1 matching on the linear PS
The first type of match we will conduct is greedy 1:1 matching, without replacement. As we had only 298 controls, we will not match all of the 698 treated patients.
Code
X <- lindner_clean$linps ## matching on the linear propensity scoreTr <-as.logical(lindner_clean$treated)match1 <-Match(Tr=Tr, X=X, M =1, replace=FALSE, ties=FALSE)
Warning in Match(Tr = Tr, X = X, M = 1, replace = FALSE, ties = FALSE):
replace==FALSE, but there are more (weighted) treated obs than control obs.
Some treated obs will not be matched. You may want to estimate ATC instead.
Code
summary(match1)
Estimate... 0
SE......... 0
T-stat..... NaN
p.val...... NA
Original number of observations.............. 996
Original number of treated obs............... 698
Matched number of observations............... 298
Matched number of observations (unweighted). 298
Below we’ll assess the match balance from the 1:1 matching.
***** (V1) stent *****
Before Matching After Matching
mean treatment........ 0.70487 0.67114
mean control.......... 0.58389 0.58389
std mean diff......... 26.505 18.54
mean raw eQQ diff..... 0.12081 0.087248
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.060489 0.043624
med eCDF diff........ 0.060489 0.043624
max eCDF diff........ 0.12098 0.087248
var ratio (Tr/Co)..... 0.85457 0.90842
T-test p-value........ 0.00032255 0.011186
***** (V2) height *****
Before Matching After Matching
mean treatment........ 171.44 171.14
mean control.......... 171.45 171.45
std mean diff......... -0.033804 -2.759
mean raw eQQ diff..... 0.56376 0.7953
med raw eQQ diff..... 0 0
max raw eQQ diff..... 20 22
mean eCDF diff........ 0.0078996 0.0105
med eCDF diff........ 0.0060095 0.0067114
max eCDF diff........ 0.024971 0.033557
var ratio (Tr/Co)..... 1.0201 1.0925
T-test p-value........ 0.99608 0.72937
KS Bootstrap p-value.. 0.958 0.912
KS Naive p-value...... 0.99947 0.99608
KS Statistic.......... 0.024971 0.033557
***** (V3) female *****
Before Matching After Matching
mean treatment........ 0.33095 0.3557
mean control.......... 0.38591 0.38591
std mean diff......... -11.672 -6.2981
mean raw eQQ diff..... 0.053691 0.030201
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.02748 0.015101
med eCDF diff........ 0.02748 0.015101
max eCDF diff........ 0.05496 0.030201
var ratio (Tr/Co)..... 0.93253 0.96707
T-test p-value........ 0.10045 0.42827
***** (V4) diabetic *****
Before Matching After Matching
mean treatment........ 0.20487 0.19128
mean control.......... 0.26846 0.26846
std mean diff......... -15.743 -19.591
mean raw eQQ diff..... 0.063758 0.077181
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.031793 0.038591
med eCDF diff........ 0.031793 0.038591
max eCDF diff........ 0.063585 0.077181
var ratio (Tr/Co)..... 0.82788 0.78767
T-test p-value........ 0.03402 0.015484
***** (V5) acutemi *****
Before Matching After Matching
mean treatment........ 0.17908 0.1745
mean control.......... 0.060403 0.060403
std mean diff......... 30.931 30.011
mean raw eQQ diff..... 0.11745 0.11409
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.05934 0.057047
med eCDF diff........ 0.05934 0.057047
max eCDF diff........ 0.11868 0.11409
var ratio (Tr/Co)..... 2.5853 2.5381
T-test p-value........ 4.6617e-09 3.8175e-06
***** (V6) ejecfrac *****
Before Matching After Matching
mean treatment........ 50.403 49.748
mean control.......... 52.289 52.289
std mean diff......... -18.102 -23.453
mean raw eQQ diff..... 2.0503 2.5403
med raw eQQ diff..... 1 1
max raw eQQ diff..... 20 20
mean eCDF diff........ 0.035602 0.045818
med eCDF diff........ 0.011423 0.028523
max eCDF diff........ 0.11383 0.13758
var ratio (Tr/Co)..... 1.0238 1.1065
T-test p-value........ 0.0085806 0.0014413
KS Bootstrap p-value.. 0.002 < 2.22e-16
KS Naive p-value...... 0.0089219 0.0070991
KS Statistic.......... 0.11383 0.13758
***** (V7) ves1proc *****
Before Matching After Matching
mean treatment........ 1.4628 1.5101
mean control.......... 1.2047 1.2047
std mean diff......... 36.545 42.62
mean raw eQQ diff..... 0.2651 0.30537
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.043323 0.050895
med eCDF diff........ 0.0090671 0.0083893
max eCDF diff........ 0.18842 0.22819
var ratio (Tr/Co)..... 2.1614 2.2254
T-test p-value........ 4.21e-11 2.2551e-11
KS Bootstrap p-value.. < 2.22e-16 < 2.22e-16
KS Naive p-value...... 7.2635e-07 3.649e-07
KS Statistic.......... 0.18842 0.22819
***** (V8) ps *****
Before Matching After Matching
mean treatment........ 0.7265 0.72904
mean control.......... 0.64061 0.64061
std mean diff......... 66.092 66.768
mean raw eQQ diff..... 0.085216 0.088433
med raw eQQ diff..... 0.081353 0.084782
max raw eQQ diff..... 0.12087 0.1245
mean eCDF diff........ 0.17141 0.17874
med eCDF diff........ 0.17768 0.17785
max eCDF diff........ 0.27599 0.28859
var ratio (Tr/Co)..... 1.1161 1.1593
T-test p-value........ < 2.22e-16 < 2.22e-16
KS Bootstrap p-value.. < 2.22e-16 < 2.22e-16
KS Naive p-value...... 3.042e-14 3.3294e-11
KS Statistic.......... 0.27599 0.28859
***** (V9) linps *****
Before Matching After Matching
mean treatment........ 1.1148 1.1369
mean control.......... 0.63332 0.63332
std mean diff......... 60.484 61.204
mean raw eQQ diff..... 0.4787 0.50359
med raw eQQ diff..... 0.35992 0.38066
max raw eQQ diff..... 1.0113 1.0858
mean eCDF diff........ 0.17141 0.17874
med eCDF diff........ 0.17768 0.17785
max eCDF diff........ 0.27599 0.28859
var ratio (Tr/Co)..... 1.672 1.7863
T-test p-value........ < 2.22e-16 < 2.22e-16
KS Bootstrap p-value.. < 2.22e-16 < 2.22e-16
KS Naive p-value...... 3.042e-14 3.3294e-11
KS Statistic.......... 0.27599 0.28859
Before Matching Minimum p.value: < 2.22e-16
Variable Name(s): ves1proc ps linps Number(s): 7 8 9
After Matching Minimum p.value: < 2.22e-16
Variable Name(s): ejecfrac ves1proc ps linps Number(s): 6 7 8 9
The new value for Rubin’s Rule 2 is 1.79. This does not pass Rubin’s Rule 2 and is not an improvement from the pre-match value of 1.67.
10 Task 5: Outcomes after 1:1 Matching Without Replacement
Since the 1:1 match without replacement didn’t produce a useful match, we’ll skip this, and move on to more fruitful uses of the propensity score in this setting.
11 Task 6 1:1 Matching With replacement
In our attempt at 1:1 matching without replacement, 400 treated participants were excluded from the sample. This is a waste of data and we’ll address this by again matching 1 treated participant to 1 control participant. However, this time we’ll match with replacement, meaning each time a control participant is matched to a treated participant, the control participant will be placed back into the pool of possible patients a treated individual can be matched to.
Thus, some control participants will be matched multiple times (not all control participants have to be matched to a treated participant). In the Lindner dataset 1:1 matching with replacement is a more reasonable choice than matching with replacement.
Code
X <- lindner_clean$linps ## matching on the linear propensity scoreTr <-as.logical(lindner_clean$treated)match1 <-Match(Tr=Tr, X=X, M =1, replace=TRUE, ties=FALSE) # notice replace = TRUEsummary(match1)
Estimate... 0
SE......... 0
T-stat..... NaN
p.val...... NA
Original number of observations.............. 996
Original number of treated obs............... 698
Matched number of observations............... 698
Matched number of observations (unweighted). 698
As you can see, this time we matched 698 treated individuals with 698 control participants. To reiterate, as we matched with replacement, and there were less control participants than treated participants, some control participants were matched multiple times.
Below we’ll assess the match balance from the 1:1 matching with replacement.
***** (V1) stent *****
Before Matching After Matching
mean treatment........ 0.70487 0.70487
mean control.......... 0.58389 0.73782
std mean diff......... 26.505 -7.2194
mean raw eQQ diff..... 0.12081 0.032951
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.060489 0.016476
med eCDF diff........ 0.060489 0.016476
max eCDF diff........ 0.12098 0.032951
var ratio (Tr/Co)..... 0.85457 1.0754
T-test p-value........ 0.00032255 0.081877
***** (V2) height *****
Before Matching After Matching
mean treatment........ 171.44 171.44
mean control.......... 171.45 171.5
std mean diff......... -0.033804 -0.52243
mean raw eQQ diff..... 0.56376 0.80372
med raw eQQ diff..... 0 0
max raw eQQ diff..... 20 22
mean eCDF diff........ 0.0078996 0.010546
med eCDF diff........ 0.0060095 0.008596
max eCDF diff........ 0.024971 0.035817
var ratio (Tr/Co)..... 1.0201 0.80708
T-test p-value........ 0.99608 0.92306
KS Bootstrap p-value.. 0.97 0.506
KS Naive p-value...... 0.99947 0.76185
KS Statistic.......... 0.024971 0.035817
***** (V3) female *****
Before Matching After Matching
mean treatment........ 0.33095 0.33095
mean control.......... 0.38591 0.2937
std mean diff......... -11.672 7.9104
mean raw eQQ diff..... 0.053691 0.037249
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.02748 0.018625
med eCDF diff........ 0.02748 0.018625
max eCDF diff........ 0.05496 0.037249
var ratio (Tr/Co)..... 0.93253 1.0674
T-test p-value........ 0.10045 0.087608
***** (V4) diabetic *****
Before Matching After Matching
mean treatment........ 0.20487 0.20487
mean control.......... 0.26846 0.21633
std mean diff......... -15.743 -2.8377
mean raw eQQ diff..... 0.063758 0.011461
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.031793 0.0057307
med eCDF diff........ 0.031793 0.0057307
max eCDF diff........ 0.063585 0.011461
var ratio (Tr/Co)..... 0.82788 0.96087
T-test p-value........ 0.03402 0.54429
***** (V5) acutemi *****
Before Matching After Matching
mean treatment........ 0.17908 0.17908
mean control.......... 0.060403 0.16046
std mean diff......... 30.931 4.854
mean raw eQQ diff..... 0.11745 0.018625
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.05934 0.0093123
med eCDF diff........ 0.05934 0.0093123
max eCDF diff........ 0.11868 0.018625
var ratio (Tr/Co)..... 2.5853 1.0913
T-test p-value........ 4.6617e-09 0.20446
***** (V6) ejecfrac *****
Before Matching After Matching
mean treatment........ 50.403 50.403
mean control.......... 52.289 50.871
std mean diff......... -18.102 -4.4965
mean raw eQQ diff..... 2.0503 0.82378
med raw eQQ diff..... 1 0
max raw eQQ diff..... 20 20
mean eCDF diff........ 0.035602 0.013079
med eCDF diff........ 0.011423 0.0057307
max eCDF diff........ 0.11383 0.065903
var ratio (Tr/Co)..... 1.0238 1.0948
T-test p-value........ 0.0085806 0.36071
KS Bootstrap p-value.. 0.002 0.03
KS Naive p-value...... 0.0089219 0.096474
KS Statistic.......... 0.11383 0.065903
***** (V7) ves1proc *****
Before Matching After Matching
mean treatment........ 1.4628 1.4628
mean control.......... 1.2047 1.457
std mean diff......... 36.545 0.81157
mean raw eQQ diff..... 0.2651 0.051576
med raw eQQ diff..... 0 0
max raw eQQ diff..... 1 1
mean eCDF diff........ 0.043323 0.008596
med eCDF diff........ 0.0090671 0.0078797
max eCDF diff........ 0.18842 0.018625
var ratio (Tr/Co)..... 2.1614 1.0956
T-test p-value........ 4.21e-11 0.82203
KS Bootstrap p-value.. < 2.22e-16 0.584
KS Naive p-value...... 7.2635e-07 0.99973
KS Statistic.......... 0.18842 0.018625
***** (V8) ps *****
Before Matching After Matching
mean treatment........ 0.7265 0.7265
mean control.......... 0.64061 0.72619
std mean diff......... 66.092 0.2413
mean raw eQQ diff..... 0.085216 0.001401
med raw eQQ diff..... 0.081353 0.0006372
max raw eQQ diff..... 0.12087 0.021689
mean eCDF diff........ 0.17141 0.0031904
med eCDF diff........ 0.17768 0.0014327
max eCDF diff........ 0.27599 0.024355
var ratio (Tr/Co)..... 1.1161 1.0082
T-test p-value........ < 2.22e-16 0.0022796
KS Bootstrap p-value.. < 2.22e-16 0.974
KS Naive p-value...... 3.042e-14 0.98578
KS Statistic.......... 0.27599 0.024355
***** (V9) linps *****
Before Matching After Matching
mean treatment........ 1.1148 1.1148
mean control.......... 0.63332 1.1079
std mean diff......... 60.484 0.86582
mean raw eQQ diff..... 0.4787 0.016273
med raw eQQ diff..... 0.35992 0.0028864
max raw eQQ diff..... 1.0113 0.75735
mean eCDF diff........ 0.17141 0.0031904
med eCDF diff........ 0.17768 0.0014327
max eCDF diff........ 0.27599 0.024355
var ratio (Tr/Co)..... 1.672 1.0465
T-test p-value........ < 2.22e-16 0.0014818
KS Bootstrap p-value.. < 2.22e-16 0.974
KS Naive p-value...... 3.042e-14 0.98578
KS Statistic.......... 0.27599 0.024355
Before Matching Minimum p.value: < 2.22e-16
Variable Name(s): ves1proc ps linps Number(s): 7 8 9
After Matching Minimum p.value: 0.0014818
Variable Name(s): linps Number(s): 9
The new value for Rubin’s Rule 1 is 0.88. This value passes Rubin’s Rule 1 and is an improvement from the Rubin’s Rule 1 value obtained during 1:1 matching without replacement, 65.53. The pre-match value was 61.87.
The new value for Rubin’s Rule 2 is 1.05. This passes Rule 2 and is an improvement from the Rubin’s Rule 2 value obtained during 1:1 matching without replacement, 1.79. The pre-match value was 1.67.
11.7 Estimating the causal effect of the treatment on both outcomes after 1:1 matching with replacement
11.7.1 The Quantitative Outcome
We’ll use a mixed model to estimate the effect of the treatment on cardbill. The matches will be treated as a random effect in the model (syntax “(1| matches.f)”. and the treatment group will be treated as a fixed effect. We will use restricted maximum likelihood (REML) to estimate coefficient values.
Code
#to appease lme4, factor the matches lindner_clean.matchedsample$matches.f <-as.factor(lindner_clean.matchedsample$matches)# fit the mixed modelmatched_mixedmodel.rep.out1 <-lmer(cardbill ~ treated + (1| matches.f), REML =TRUE, data=lindner_clean.matchedsample)summary(matched_mixedmodel.rep.out1)
Linear mixed model fit by REML ['lmerMod']
Formula: cardbill ~ treated + (1 | matches.f)
Data: lindner_clean.matchedsample
REML criterion at convergence: 30291.8
Scaled residuals:
Min 1Q Median 3Q Max
-1.0996 -0.4634 -0.2698 0.1000 12.7641
Random effects:
Groups Name Variance Std.Dev.
matches.f (Intercept) 3053638 1747
Residual 155732228 12479
Number of obs: 1396, groups: matches.f, 698
Fixed effects:
Estimate Std. Error t value
(Intercept) 16300 477 34.174
treated -173 668 -0.259
Correlation of Fixed Effects:
(Intr)
treated -0.700
Treated individuals were estimated to spend $-173.03 (95% CI:-1482.29, 1136.22) as compared to non-treated individuals: so the treated will spend somewhat less. Zero is in that 95% CI, so a formal sensitivity analysis on the Quantitative outcome using Rosenbaum bounds will still not make sense.
Code
#sanity check for modellindner_clean.matchedsample %>%group_by(treated_f) %>%summarise(mean_card =mean(cardbill))
# A tibble: 2 × 2
treated_f mean_card
<fct> <dbl>
1 treated 16127.
2 control 16300.
The mixed model above predicted treated individuals would spend roughly $-173.03 as compared to control participants. After doing a quick check of the mean cardbill within the matched sample, the mixed model results make sense.
11.7.2 The Binary Outcome
We will use conditional logistic regression to estimate the log odds (and ORs) of being alive after 6 months based on treatment status.
The odds of being alive after six months were 5.6 times higher in treated individuals than non-treated controls (95% CI: 2.86, 10.98)
12 Task 7: Subclassification by Propensity Score Quintile
Code
#cut into quintileslindner_clean$stratum <- Hmisc::cut2(lindner_clean$ps, g=5)lindner_clean$quintile <-factor(lindner_clean$stratum, labels=1:5)#Sanity check: check to make sure quintiles are similar in size, etc.lindner_clean %>%count(stratum, quintile)
12.1 Check Balance and Propensity Score Overlap in Each Quintile
12.1.1 Numerically
Only 20 controls were were in the largest quintile, which seems a bit low.
Code
lindner_clean %>%count(quintile, treated_f)
# A tibble: 10 × 3
quintile treated_f n
<fct> <fct> <int>
1 1 treated 105
2 1 control 95
3 2 treated 124
4 2 control 75
5 3 treated 135
6 3 control 65
7 4 treated 156
8 4 control 43
9 5 treated 178
10 5 control 20
12.1.2 Graphically
Code
ggplot(lindner_clean, aes(x = treated_f, y =round(ps,2), group = quintile, color = treated_f)) +geom_jitter(width =0.2) +guides(color ="none") +facet_wrap(~ quintile, scales ="free_y") +labs(x ="", y ="Propensity for Treatment",title ="Quintile Subclassification in the Lindner data")
12.2 Creating a Standardized Difference Calculation Function
Here we implement Dr. Love’s function to calculate the standardizes differences is utilized below.
Now we can plot the standardized differences after ATT weighting.
Code
ggplot(balance.att.weights, aes(x = szd, y =reorder(names, szd), color = timing)) +geom_point() +geom_vline(xintercept =0) +geom_vline(xintercept =c(-10,10), linetype ="dashed", col ="blue") +labs(x ="Standardized Difference", y ="",title ="Standardized Difference before and after ATT Weighting")
The standardized differences look much better here in this approach.
14.2.1 Rubin’s Rules
14.2.1.1 Rule 1
Numbers from linps row in part [2] of balance table above: (-0.048 * 100) = 4.8%. So passes Rule 1.
14.2.1.2 Rule 2
Numbers from linps row in part [2] of balance table above: (0.7962)/(0.8392) = 0.9001237. Passes Rule 2
14.2.2 Estimated effect on outcomes after ATT weighting
14.2.2.1 Quantitative outcome
To estimate the effect of the treatment on cardbill, we’ll use svyglm from the survey package to apply the ATT weights in a linear model.
Code
lindnerwt1.design <-svydesign(ids=~1, weights=~wts1, data=lindner_clean) # using ATT weightsadjout1.wt1 <-svyglm(cardbill ~ treated, design=lindnerwt1.design)wt_att_results1 <-tidy(adjout1.wt1, conf.int =TRUE) %>%filter(term =="treated")wt_att_results1 |>kable(digits =3)
ggplot(balance.ate.weights, aes(x = szd, y =reorder(names, szd), color = timing)) +geom_point() +geom_vline(xintercept =0) +geom_vline(xintercept =c(-10,10), linetype ="dashed", col ="blue") +labs(x ="Standardized Difference", y ="",title ="Standardized Difference before and after ATE Weighting")
Again, the standardized differences look good here.
14.3.1 Rubin’s Rules
14.3.1.1 Rule 1
-0.034*100 = 3.4%. Passes Rule 1 (numbers from ATE weight balance table above).
14.3.1.2 Rule 2
(0.7742)/(0.8152) = 0.9019173. Passes Rule 2 (numbers from ATE weight balance table above).
14.3.2 Estimated effect on outcomes after ATE weighting
14.3.2.1 Quantitative outcome
Code
lindnerwt2.design <-svydesign(ids=~1, weights=~wts2, data=lindner_clean) # using ATE weightsadjout1.wt2 <-svyglm(cardbill ~ treated, design=lindnerwt2.design)wt_ate_results1 <-tidy(adjout1.wt2, conf.int =TRUE) %>%filter(term =="treated")wt_ate_results1 |>kable(digits =3)
p <-love.plot(bal.tab(ps.toy),threshold = .1, size =1.5,title ="Standardized Differences and TWANG ATT Weighting")
Warning: Standardized mean differences and raw mean differences are present in the same plot.
Use the `stars` argument to distinguish between them and appropriately label the x-axis.
Code
p
Compared to the manual ATT/ATE weights, the standardized differences look a bit worse here.
15.1 Estimated effect on outcomes after TWANG ATT weighting
15.1.1 Quantitative outcome
Code
toywt3.design <-svydesign(ids=~1,weights=~get.weights(ps.toy,stop.method ="es.mean"),data=lindner_clean) # using twang ATT weightsadjout1.wt3 <-svyglm(cardbill ~ treated, design=toywt3.design)wt_twangatt_results1 <-tidy(adjout1.wt3, conf.int =TRUE) %>%filter(term =="treated")wt_twangatt_results1 |>kable(digits =3)
Kereiakes DJ, Obenchain RL, Barber BL, et al. Abciximab provides cost effective survival advantage in high volume interventional practice. Am Heart J 2000; 140: 603-610.↩︎
Source Code
---title: "The Lindner Example"author: "Wyatt P. Bensken and Harry Persaud"date: last-modifiedformat: html: toc: true number-sections: true code-fold: show code-tools: true code-overflow: wrap embed-resources: true date-format: iso theme: pulse ---### Authorship Note {.unnumbered}Wyatt P. Bensken and Harry Persaud built this example in 2020-2022. Thomas Love converted it to Quarto in 2023, and made some edits. If you notice errors or encounter problems, contact Thomas dot Love at case dot edu.# Setup ```{r}#| message: false#| warning: falselibrary(knitr)opts_chunk$set(comment =NA) options(max.print="250")opts_knit$set(width=75)library(janitor) library(broom)library(patchwork)library(cobalt)library(Matching)library(tableone)library(survey)library(survival)library(twang)library(naniar)library(magrittr)library(lme4)library(tidyverse) # load tidyverse last## Note that we will also use the broom.mixed package## but we won't load it heredecim <-function(x, k) format(round(x, k), nsmall=k)theme_set(theme_light()) # set theme for ggplots```Note: we will also use the `broom.mixed` package, but we are not loading it as to prevent it conflicting with the functions of `broom`.# Load dataInformation on the lindner dataset can be found at [at this site](https://search.r-project.org/CRAN/refmans/twang/html/lindner.html)[^1].[^1]: Kereiakes DJ, Obenchain RL, Barber BL, et al. Abciximab provides cost effective survival advantage in high volume interventional practice. *Am Heart J* 2000; 140: 603-610.```{r}lindner_raw <-read_csv("data/lindner.csv", show_col_types =FALSE)lindner_raw n_miss(lindner_raw)```After reading in the data, we can print the first 10 rows to get a sense of what our data looks like. We see the tibble contains information on 996 participants, and there is no missing data.# Data managment## Managing binary variablesIn the course of this example, we'll want both a numeric and factored version of each binary variable. - In all numeric versions of binary variables: 1 indicates 'yes' to having trait/characteristic, 0 indicates 'no' to having trait/characteristic.- Variable names with trailing "_f" denotes the factored version of each binary variable.```{r}# Six month survival (turning logical variable to a factor)lindner_raw$sixMonthSurvive_f <-factor(lindner_raw$sixMonthSurvive, levels =c(TRUE,FALSE),labels =c("yes", "no"))# Creating numeric (1/0) version of six month survival variablelindner_raw$sixMonthSurvive <-factor(lindner_raw$sixMonthSurvive_f, levels =c("yes","no"),labels =c(1, 0))lindner_raw$sixMonthSurvive <-ifelse(lindner_raw$sixMonthSurvive =="1", 1, 0)#Add variable named treated (same values as abcix variable)lindner_raw$treated <- lindner_raw$abcix# Factoring the exposure of interest variable. Change the name to 'treated' too.lindner_raw$treated_f <-factor(lindner_raw$abcix, levels =c(1,0),labels =c("treated", "control"))# Factor version of stent variablelindner_raw$stent_f <-factor(lindner_raw$stent, levels =c(1,0),labels =c("yes", "no"))# Factoring the female variablelindner_raw$female_f <-factor(lindner_raw$female, levels =c(1,0),labels =c("female", "male"))# Factoring the diabetic variablelindner_raw$diabetic_f <-factor(lindner_raw$diabetic, levels =c(1,0),labels =c("yes", "no"))# Factoring the acutemi variablelindner_raw$acutemi_f <-factor(lindner_raw$acutemi, levels =c(1,0),labels =c("yes", "no"))# Add patientidlindner_raw <- tibble::rowid_to_column(lindner_raw, "patientid")# Make lindner dataset with "clean" name. lindner_clean <- lindner_raw```## Inspecting the clean data```{r}mosaic::inspect(lindner_clean)```# CodebookInformation was copy/pasted from [here](https://www.rdocumentation.org/packages/MatchLinReg/versions/0.7.0/topics/lindner) (with some changes to reflect this analysis)- `cardbill` (**Quantitative Outcome**): "Cardiac related costs incurred within 6 months of patient's initial PCI; numeric value in 1998 dollars; costs were truncated by death for the 26 patients with lifepres == 0."- `sixMonthSurvive`/`sixMonthSurvive_f` (**Binary Outcome**): "Survival at six months a recoded version of lifepres."- `treated`/`treated_f` (**Exposure**): "Numeric treatment selection indicator; 0 implies usual PCI care alone; 1 implies usual PCI care deliberately augmented by either planned or rescue treatment with abciximab."- `stent`/`stent_f`: "Coronary stent deployment; numeric, with 1 meaning YES and 0 meaning NO."- `height`: "Height in centimeters; numeric integer from 108 to 196."- `female`/`female_f`: "Female gender; numeric, with 1 meaning YES and 0 meaning NO."- `diabetic`/`diabetic_f`: "Diabetes mellitus diagnosis; numeric, with 1 meaning YES and 0 meaning NO."- `acutemi`/`acutemi_f`: "Acute myocardial infarction within the previous 7 days; numeric, with 1 meaning YES and 0 meaning NO."- `ejecfrac`: "Left ejection fraction; numeric value from 0 percent to 90 percent."- `ves1proc`: "Number of vessels involved in the patient's initial PCI procedure; numeric integer from 0 to 5."- `patientid`: a made-up order of numbers to assign patient IDs for later use* Note: Percutaneous Coronary Intervention (PCI)# Table 1```{r}var_list =c("cardbill", "sixMonthSurvive_f", "stent_f", "height", "female_f", "diabetic_f", "acutemi_f", "ejecfrac", "ves1proc")factor_list =c("sixMonthSurvive_f", "stent_f", "female_f", "diabetic_f", "acutemi_f")CreateTableOne(vars = var_list, strata ="treated_f",data = lindner_clean, factorVars = factor_list)```As we can see, the mean `cardbill` was higher in the treated population and a larger percentage of controls did not survive through 6 months.# Task 1: Ignoring covariates, estimate the effect of treatment vs. control on the two outcomes## Quantitative outcome: `cardbill````{r}lindner_clean %$% mosaic::favstats(cardbill ~ treated_f)```- Across the entire sample, the mean (\$16,127 vs. \$14,614) and median (\$12,944 vs. \$10,423) cardiac care costs were higher in treated individuals than non-treated controls.```{r}ggplot(lindner_clean, aes(x = cardbill, fill ="cardbill")) +geom_histogram(bins =20) +labs(y ="Count",x ="Cardiac care costs ($)",title ="Cardbill appears to be right skewed") +guides(fill ="none")```As we can see in this figure, `cardbill` appears to be right/positively skewed.```{r}unadjust_quant_outcome <-lm(cardbill ~ treated, data = lindner_clean)unadjust_quant_outcome_tidy <-tidy(unadjust_quant_outcome, conf.int =TRUE, conf.level =0.95) %>%filter(term =="treated")unadjust_quant_outcome_tidy |>kable(digits =3)```Treated individuals were estimated to spend `r round(unadjust_quant_outcome_tidy$estimate,2)` (95% CI: `r round(unadjust_quant_outcome_tidy$conf.low,2)`, `r round(unadjust_quant_outcome_tidy$conf.high,2)`) more dollars than non-treated controls ## Binary outcome: `sixMonthSurvive````{r}Epi::twoby2(table(lindner_clean$treated_f, lindner_clean$sixMonthSurvive_f))```The odds treated individuals were alive after 6 months was roughly 3.31 times the odds that non-treated individuals were alive after 6 months.```{r}unadjust_binary_outcome <-glm(sixMonthSurvive ~ treated, data = lindner_clean, family =binomial())unadjust_binary_outcome_tidy <-tidy(unadjust_binary_outcome, conf.int =TRUE, conf.level =0.95, exponentiate =TRUE) %>%filter(term =="treated")unadjust_binary_outcome_tidy |>kable(digits =3)```The odds of being alive after six months in treated individuals was `r round(unadjust_binary_outcome_tidy$estimate,2)` (95% CI: `r round(unadjust_binary_outcome_tidy$conf.low,2)`, `r round(unadjust_binary_outcome_tidy$conf.high,2)`) times higher than the odds that a non-treated control would be alive after six months.# Task 2: Fitting the propensity score modelWe will now fit the propensity score, which predicts treatment status based on available covariates. Remember, we're not worried about overfitting (including too many covariates) when calculating the propensity scores.```{r}psmodel <-glm(treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc, family =binomial(), data =lindner_clean)summary(psmodel)```Store the raw and linear propensity scores below.```{r}lindner_clean$ps <- psmodel$fittedlindner_clean$linps <- psmodel$linear.predictors```## Comparing distribution of propensity scores across treatment groupsHere's a numerical description.```{r}lindner_clean %$% mosaic::favstats(ps ~ treated_f) |>kable(digits =3)```We can see there are no propensity scores equal to, or very close to, 0 or 1. Now, let's draw some plots.### Boxplot Now we'll visualize the distribution of the propensity scores stratified by treatment status. ```{r}ggplot(lindner_clean, aes(x = treated_f, y = ps, color = treated_f)) +geom_boxplot() +geom_jitter(width =0.1) +guides(color ="none") +labs(x ="",title ="Raw propensity scores, stratified by exposure group")```### Density plot```{r}ggplot(lindner_clean, aes(x = linps, fill = treated_f)) +geom_density(alpha =0.3) ```Both of these plots demonstrate good overlap, suggesting a propensity score analysis may be appropriate.# Task 3: Rubin's Rules For Assessing Overlap Before Propensity Adjustment## Rubin's Rule 1```{r}rubin1.unadj <-with(lindner_clean,abs(100*(mean(linps[treated==1])-mean(linps[treated==0]))/sd(linps)))rubin1.unadj```As you can see, we fail Rubin's Rule 1 - in which we want below 50%.## Rubin's Rule 2```{r}rubin2.unadj <-with(lindner_clean, var(linps[treated==1])/var(linps[treated==0]))rubin2.unadj```We also "fail" Rubin's Rule 2 where we are looking for value between 0.8 - 1.2 (ideally, 1).# Task 4: Greedy 1:1 matching on the linear PSThe first type of match we will conduct is greedy 1:1 matching, without replacement. As we had only 298 controls, we will not match all of the 698 treated patients.```{r}X <- lindner_clean$linps ## matching on the linear propensity scoreTr <-as.logical(lindner_clean$treated)match1 <-Match(Tr=Tr, X=X, M =1, replace=FALSE, ties=FALSE)summary(match1)```Below we'll assess the match balance from the 1:1 matching.```{r}set.seed(123)mb1 <-MatchBalance(treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc + ps + linps, data=lindner_clean, match.out = match1, nboots=500)``````{r}covnames <-c("stent", "height", "female", "diabetic", "acutemi", "ejecfrac", "ves1proc", "ps", "linps")```This is Dr. Love's code to extract the standardized differences.```{r}pre.szd <-NULL; post.szd <-NULLfor(i in1:length(covnames)) {pre.szd[i] <- mb1$BeforeMatching[[i]]$sdiff.pooledpost.szd[i] <- mb1$AfterMatching[[i]]$sdiff.pooled}```We can now print our table of standardized differences.```{r}match_szd <-data.frame(covnames, pre.szd, post.szd)print(match_szd, digits=3)```We don't really need to go on here. The post-matching standardized difference (for the linear propensity score) are barely improved. ## Love Plot of standardized differences before and after 1:1 matching without replacement## Using ggplotIn this figure, blue points are post-matching while white are pre-match```{r}lp_wo_rep <-ggplot(match_szd, aes(x = pre.szd, y =reorder(covnames, pre.szd))) +geom_point(col ="black", size =3, pch =1) +geom_point(aes(x = post.szd, y =reorder(covnames, pre.szd)), size =3, col ="blue") +geom_vline(aes(xintercept =0)) +geom_vline(aes(xintercept =10), linetype ="dashed", col ="red") +geom_vline(aes(xintercept =-10), linetype ="dashed", col ="red") +labs(x ="Standardized Difference (%)", y ="",title ="Love Plot",subtitle ="1:1 matching without replacement")lp_wo_rep```Just visually, we can see this match is dreadful.## Creating a dataframe containing the matched sampleWe will created a data frame which includes our matched sample, and do a quick count for a sanity check.```{r}matches <-factor(rep(match1$index.treated, 2))lindner_clean.matchedsample <-cbind(matches, lindner_clean[c(match1$index.control, match1$index.treated),])lindner_clean.matchedsample %>%count(treated_f)```## Reassessing Rubin's Rules after 1:1 matching without replacement### Rubin's Rule 1```{r}rubin1.match <-with(lindner_clean.matchedsample,abs(100*(mean(linps[treated==1])-mean(linps[treated==0]))/sd(linps)))rubin1.match```The new value for Rubin's Rule 1 is `r round(rubin1.match,2)`. This is not an improvement from the pre-match value of `r round(rubin1.unadj,2)`. We continue to fail Rubin's Rule 1.### Rubin's Rule 2```{r}rubin2.match <-with(lindner_clean.matchedsample, var(linps[treated==1])/var(linps[treated==0]))rubin2.match```The new value for Rubin's Rule 2 is `r round(rubin2.match,2)`. This does not pass Rubin's Rule 2 and is not an improvement from the pre-match value of `r round(rubin2.unadj,2)`.# Task 5: Outcomes after 1:1 Matching Without ReplacementSince the 1:1 match without replacement didn't produce a useful match, we'll skip this, and move on to more fruitful uses of the propensity score in this setting.# Task 6 1:1 Matching With replacementIn our attempt at 1:1 matching without replacement, 400 treated participants were excluded from the sample. This is a waste of data and we'll address this by again matching 1 treated participant to 1 control participant. However, this time we'll match with replacement, meaning each time a control participant is matched to a treated participant, the control participant will be placed back into the pool of possible patients a treated individual can be matched to. Thus, some control participants will be matched multiple times (not all control participants have to be matched to a treated participant). In the Lindner dataset 1:1 matching with replacement is a more reasonable choice than matching with replacement.```{r}X <- lindner_clean$linps ## matching on the linear propensity scoreTr <-as.logical(lindner_clean$treated)match1 <-Match(Tr=Tr, X=X, M =1, replace=TRUE, ties=FALSE) # notice replace = TRUEsummary(match1)```As you can see, this time we matched 698 treated individuals with 698 control participants. To reiterate, as we matched with replacement, and there were less control participants than treated participants, some control participants were matched multiple times.Below we'll assess the match balance from the 1:1 matching with replacement.```{r}set.seed(202102)mb1 <-MatchBalance(treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc + ps + linps, data=lindner_clean,match.out = match1, nboots=500)``````{r}covnames <-c("stent", "height", "female", "diabetic", "acutemi", "ejecfrac", "ves1proc", "ps", "linps")```Dr. Love's code to extract the standardized differences.```{r}pre.szd <-NULL; post.szd <-NULLfor(i in1:length(covnames)) {pre.szd[i] <- mb1$BeforeMatching[[i]]$sdiff.pooledpost.szd[i] <- mb1$AfterMatching[[i]]$sdiff.pooled}```Table of standardized differences```{r}match_szd <-data.frame(covnames, pre.szd, post.szd, row.names=covnames)print(match_szd, digits=3)```## Love Plot of standardized differences before and after 1:1 matching## Using ggplotIn this figure, blue points are post-matching while white are pre-match.```{r}lp_w_rep <-ggplot(match_szd, aes(x = pre.szd, y =reorder(covnames, pre.szd))) +geom_point(col ="black", size =3, pch =1) +geom_point(aes(x = post.szd, y =reorder(covnames, pre.szd)), size =3, col ="blue") +geom_vline(aes(xintercept =0)) +geom_vline(aes(xintercept =10), linetype ="dashed", col ="red") +geom_vline(aes(xintercept =-10), linetype ="dashed", col ="red") +labs(x ="Standardized Difference (%)", y ="",title ="Love Plot",subtitle ="1:1 matching with replacement")lp_w_rep```- Visually, the Love Plot using 1:1 matching with replacement looks pretty good.```{r}# comparison of love plots with and without replacementlp_wo_rep + lp_w_rep```When we look at the plots without replacement and with replacement side-by-side, it definitely looks better than the 1:1 matching without replacement.## Using `cobalt` to make the Love PlotAgain, we can also use an automated way to make the Love Plot.```{r}cobalt_tab <-bal.tab(match1, treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc + ps + linps, data=lindner_clean, un =TRUE,stats =c("m","v"))cobalt_tab``````{r}p <-love.plot(cobalt_tab, threshold = .1, size =3,var.order ="unadjusted",title ="Standardized Differences after 1:1 Matching With Replacement",stars ="std")p```## Extracting Variance Ratios```{r}pre.vratio <-NULL; post.vratio <-NULLfor(i in1:length(covnames)) {pre.vratio[i] <- mb1$BeforeMatching[[i]]$var.ratiopost.vratio[i] <- mb1$AfterMatching[[i]]$var.ratio}## Table of Variance Ratiosmatch_vrat <-data.frame(names = covnames, pre.vratio, post.vratio, row.names=covnames)print(match_vrat, digits=2)```### Using 'cobalt' to make a Love Plot of Variance Ratios```{r}p <-love.plot(cobalt_tab, stats ="v", threshold = .1, size =3,var.order ="unadjusted",title ="Variance Ratios after 1:1 Matching With Replacement",stars ="")p```## Creating a dataframe containing the matched sample```{r}matches <-factor(rep(match1$index.treated, 2))lindner_clean.matchedsample <-cbind(matches, lindner_clean[c(match1$index.control, match1$index.treated),])lindner_clean.matchedsample %>%count(treated_f)```### How many times were the non-treated patients matched?```{r}lindner_clean.matchedsample %>%filter(treated_f =="control") %>%count(patientid) %>% janitor::tabyl(n)```## Reassessing Rubin's Rules after 1:1 matching with replacement### Rubin's Rule 1```{r}rubin1.match.rep <-with(lindner_clean.matchedsample,abs(100*(mean(linps[treated==1])-mean(linps[treated==0]))/sd(linps)))rubin1.match.rep```The new value for Rubin's Rule 1 is `r round(rubin1.match.rep, 2)`. This value passes Rubin's Rule 1 and is an improvement from the Rubin's Rule 1 value obtained during 1:1 matching without replacement, `r round(rubin1.match,2)`. The pre-match value was `r round(rubin1.unadj,2)`.### Rubin's Rule 2```{r}rubin2.match.rep <-with(lindner_clean.matchedsample, var(linps[treated==1])/var(linps[treated==0]))rubin2.match.rep```The new value for Rubin's Rule 2 is `r round(rubin2.match.rep, 2)`. This passes Rule 2 and is an improvement from the Rubin's Rule 2 value obtained during 1:1 matching without replacement, `r round(rubin2.match,2)`. The pre-match value was `r round(rubin2.unadj,2)`.## Estimating the causal effect of the treatment on both outcomes after 1:1 matching with replacement### The Quantitative OutcomeWe'll use a mixed model to estimate the effect of the treatment on `cardbill`. The matches will be treated as a random effect in the model (syntax "(1| matches.f)". and the treatment group will be treated as a fixed effect. We will use restricted maximum likelihood (REML) to estimate coefficient values.```{r}#to appease lme4, factor the matches lindner_clean.matchedsample$matches.f <-as.factor(lindner_clean.matchedsample$matches)# fit the mixed modelmatched_mixedmodel.rep.out1 <-lmer(cardbill ~ treated + (1| matches.f), REML =TRUE, data=lindner_clean.matchedsample)summary(matched_mixedmodel.rep.out1)confint(matched_mixedmodel.rep.out1)``````{r, warning=FALSE, message=FALSE}tidy_mixed_matched_rep <- broom.mixed::tidy(matched_mixedmodel.rep.out1, conf.int =TRUE, conf.level =0.95) %>%filter(term =="treated")tidy_mixed_matched_rep |>kable(digits =3)```Treated individuals were estimated to spend \$`r round(tidy_mixed_matched_rep$estimate,2)` (95% CI:`r round(tidy_mixed_matched_rep$conf.low,2)`, `r round(tidy_mixed_matched_rep$conf.high,2)`) as compared to non-treated individuals: so the treated will spend somewhat less. Zero is in that 95% CI, so a formal sensitivity analysis on the Quantitative outcome using Rosenbaum bounds will still not make sense.```{r}#sanity check for modellindner_clean.matchedsample %>%group_by(treated_f) %>%summarise(mean_card =mean(cardbill))```- The mixed model above predicted treated individuals would spend roughly \$`r round(tidy_mixed_matched_rep$estimate,2)` as compared to control participants. After doing a quick check of the mean `cardbill` within the matched sample, the mixed model results make sense. ### The Binary OutcomeWe will use conditional logistic regression to estimate the log odds (and ORs) of being alive after 6 months based on treatment status.```{r}binary_outcome_adjusted_rep <- survival::clogit(sixMonthSurvive ~ treated +strata(matches), data=lindner_clean.matchedsample)summary(binary_outcome_adjusted_rep)``````{r}#Tidy modeltidy_binary_outcome_adjusted_rep <-tidy(binary_outcome_adjusted_rep, exponentiate =TRUE, conf.int =0.95)tidy_binary_outcome_adjusted_rep |>kable(digits =3)```The odds of being alive after six months were `r round(tidy_binary_outcome_adjusted_rep$estimate,2)` times higher in treated individuals than non-treated controls (95% CI: `r round(tidy_binary_outcome_adjusted_rep$conf.low,2)`, `r round(tidy_binary_outcome_adjusted_rep$conf.high,2)`)# Task 7: Subclassification by Propensity Score Quintile```{r}#cut into quintileslindner_clean$stratum <- Hmisc::cut2(lindner_clean$ps, g=5)lindner_clean$quintile <-factor(lindner_clean$stratum, labels=1:5)#Sanity check: check to make sure quintiles are similar in size, etc.lindner_clean %>%count(stratum, quintile) ```## Check Balance and Propensity Score Overlap in Each Quintile### NumericallyOnly 20 controls were were in the largest quintile, which seems a bit low. ```{r}lindner_clean %>%count(quintile, treated_f)```### Graphically```{r}ggplot(lindner_clean, aes(x = treated_f, y =round(ps,2), group = quintile, color = treated_f)) +geom_jitter(width =0.2) +guides(color ="none") +facet_wrap(~ quintile, scales ="free_y") +labs(x ="", y ="Propensity for Treatment",title ="Quintile Subclassification in the Lindner data")```## Creating a Standardized Difference Calculation FunctionHere we implement Dr. Love's function to calculate the standardizes differences is utilized below.```{r}szd <-function(covlist, g) {covlist2 <-as.matrix(covlist)g <-as.factor(g)res <-NAfor(i in1:ncol(covlist2)) {cov <-as.numeric(covlist2[,i])num <-100*diff(tapply(cov, g, mean, na.rm=TRUE))den <-sqrt(mean(tapply(cov, g, var, na.rm=TRUE)))res[i] <-round(num/den,2)}names(res) <-names(covlist)res}```Now we'll split data into quintiles - and give them each their own dataframe.```{r}quin1 <-filter(lindner_clean, quintile==1)quin2 <-filter(lindner_clean, quintile==2)quin3 <-filter(lindner_clean, quintile==3)quin4 <-filter(lindner_clean, quintile==4)quin5 <-filter(lindner_clean, quintile==5)```Now we'll run the function above to calculate the standardized differences for each covariate in each quintile.```{r}covs <-c("stent", "height", "female", "diabetic", "acutemi", "ejecfrac", "ves1proc", "ps", "linps")d.q1 <-szd(quin1[covs], quin1$treated)d.q2 <-szd(quin2[covs], quin2$treated)d.q3 <-szd(quin3[covs], quin3$treated)d.q4 <-szd(quin4[covs], quin4$treated)d.q5 <-szd(quin5[covs], quin5$treated)d.all <-szd(lindner_clean[covs], lindner_clean$treated)lindner_clean.szd <-tibble(covs, Overall = d.all, Q1 = d.q1, Q2 = d.q2, Q3 = d.q3, Q4 = d.q4, Q5 = d.q5)lindner_clean.szd <-gather(lindner_clean.szd, "quint", "sz.diff", 2:7)```## Plotting the post-subclassification standardized differences ```{r}ggplot(lindner_clean.szd, aes(x = sz.diff, y =reorder(covs, -sz.diff), group = quint)) +geom_point() +geom_vline(xintercept =0) +geom_vline(xintercept =c(-10,10), linetype ="dashed", col ="blue") +facet_wrap(~ quint) +labs(x ="Standardized Difference, %", y ="",title ="Comparing Standardized Differences by PS Quintile")```The results of the standardized differences by quintile are fairly variable.## Rubin's Rules post subclassification### Rule 1```{r}rubin1.q1 <-with(quin1, abs(100*(mean(linps[treated==1]) -mean(linps[treated==0]))/sd(linps)))rubin1.q2 <-with(quin2, abs(100*(mean(linps[treated==1]) -mean(linps[treated==0]))/sd(linps)))rubin1.q3 <-with(quin3, abs(100*(mean(linps[treated==1]) -mean(linps[treated==0]))/sd(linps)))rubin1.q4 <-with(quin4, abs(100*(mean(linps[treated==1]) -mean(linps[treated==0]))/sd(linps)))rubin1.q5 <-with(quin5, abs(100*(mean(linps[treated==1]) -mean(linps[treated==0]))/sd(linps)))rubin1.sub <-c(rubin1.q1, rubin1.q2, rubin1.q3, rubin1.q4, rubin1.q5)names(rubin1.sub)=c("Q1", "Q2", "Q3", "Q4", "Q5")rubin1.sub```All are under 50. Not great, but OK. For comparison, the original Rubin's Rule 1 value was 61.87.### Rule 2```{r}rubin2.q1 <-with(quin1, var(linps[treated==1])/var(linps[treated==0]))rubin2.q2 <-with(quin2, var(linps[treated==1])/var(linps[treated==0]))rubin2.q3 <-with(quin3, var(linps[treated==1])/var(linps[treated==0]))rubin2.q4 <-with(quin4, var(linps[treated==1])/var(linps[treated==0]))rubin2.q5 <-with(quin5, var(linps[treated==1])/var(linps[treated==0]))rubin2.sub <-c(rubin2.q1, rubin2.q2, rubin2.q3, rubin2.q4, rubin2.q5)names(rubin2.sub)=c("Q1", "Q2", "Q3", "Q4", "Q5")rubin2.sub```All but Q1 are at least close to passing Rule 2. For comparison, the original Rubin's Rule 2 value was 1.67.# Task 8: Estimated effect after subclassification## Quantitative outcome```{r}quin1.out1 <-lm(cardbill ~ treated, data=quin1)quin2.out1 <-lm(cardbill ~ treated, data=quin2)quin3.out1 <-lm(cardbill ~ treated, data=quin3)quin4.out1 <-lm(cardbill ~ treated, data=quin4)quin5.out1 <-lm(cardbill ~ treated, data=quin5)coef(summary(quin1.out1)); coef(summary(quin2.out1)); coef(summary(quin3.out1)); coef(summary(quin4.out1)); coef(summary(quin5.out1)) ```The mean of the five quintile-specific estimated regression coefficients is below.```{r}est.st <- (coef(quin1.out1)[2] +coef(quin2.out1)[2] +coef(quin3.out1)[2] +coef(quin4.out1)[2] +coef(quin5.out1)[2])/5est.st```The mean SE is below.```{r}se.q1 <-summary(quin1.out1)$coefficients[2,2]se.q2 <-summary(quin2.out1)$coefficients[2,2]se.q3 <-summary(quin3.out1)$coefficients[2,2]se.q4 <-summary(quin4.out1)$coefficients[2,2]se.q5 <-summary(quin5.out1)$coefficients[2,2]se.st <-sqrt((se.q1^2+ se.q2^2+ se.q3^2+ se.q4^2+ se.q5^2)*(1/25))se.st```The mean estimate, with a 95% CI, is below.```{r}strat.result1 <-tibble(estimate = est.st,conf.low = est.st -1.96*se.st,conf.high = est.st +1.96*se.st)strat.result1```So treated individuals were estimated to spend \$`r round(strat.result1$estimate, 2)` more (95% CI:`r round(strat.result1$conf.low, 2)`, `r round(strat.result1$conf.high, 2)`) than non treated individuals.## Binary Outcome```{r}quin1.out2 <-glm(sixMonthSurvive ~ treated, data=quin1, family=binomial())quin2.out2 <-glm(sixMonthSurvive ~ treated, data=quin2, family=binomial())quin3.out2 <-glm(sixMonthSurvive ~ treated, data=quin3, family=binomial())quin4.out2 <-glm(sixMonthSurvive ~ treated, data=quin4, family=binomial())quin5.out2 <-glm(sixMonthSurvive ~ treated, data=quin5, family=binomial())coef(summary(quin1.out2)); coef(summary(quin2.out2)); coef(summary(quin3.out2)); coef(summary(quin4.out2)); coef(summary(quin5.out2))```Estimated log-odds (averaged over the quintiles).```{r}est.st.log <- (coef(quin1.out2)[2] +coef(quin2.out2)[2] +coef(quin3.out2)[2] +coef(quin4.out2)[2] +coef(quin5.out2)[2])/5est.st.log```Estimated odds ratio (averaged over the quintiles).```{r}exp(est.st.log)```The average SE (averaged over the quintiles).```{r}se.q1.log <-summary(quin1.out2)$coefficients[2,2]se.q2.log <-summary(quin2.out2)$coefficients[2,2]se.q3.log <-summary(quin3.out2)$coefficients[2,2]se.q4.log <-summary(quin4.out2)$coefficients[2,2]se.q5.log <-summary(quin5.out2)$coefficients[2,2]se.st.log <-sqrt((se.q1.log^2+ se.q2.log^2+ se.q3.log^2+ se.q4.log^2+ se.q5.log^2)*(1/25))se.st.log #log odds``````{r}strat.result2 <-tibble(estimate =exp(est.st.log),conf.low =exp(est.st.log -1.96*se.st.log),conf.high =exp(est.st.log +1.96*se.st.log))strat.result2```The odds of being alive after 6 months was `r round(strat.result2$estimate, 2)` (95% CI:`r round(strat.result2$conf.low, 2)`, `r round(strat.result2$conf.high, 2)`) times higher in treated individuals than non-treated individuals. # Task 9: Weighting## Calculating the ATT and ATE weights### ATT weightsFirst, we can use the average treatment effect on the treated (ATT) approach where we weight treated subjects as 1 and controls as ps/(1-ps)```{r}lindner_clean$wts1 <-ifelse(lindner_clean$treated==1, 1, lindner_clean$ps/(1-lindner_clean$ps))```### ATE weightsWe can also use the average treatment effect (ATE) weights where we weight treated subjects by 1/ps and controls by 1/(1-PS)```{r}lindner_clean$wts2 <-ifelse(lindner_clean$treated==1, 1/lindner_clean$ps, 1/(1-lindner_clean$ps))```## Working with the ATT weights```{r}ggplot(lindner_clean, aes(x = ps, y = wts1, color = treated_f)) +geom_point() +guides(color ="none") +facet_wrap(~ treated_f) +labs(x ="Estimated Propensity for Treatment",y ="ATT weight",title ="ATT weighting structure")``````{r}#turn dataset into a dataframe for twang (its a tibble now)lindner_clean_df <-data.frame(lindner_clean)#name covariatescovlist <-c("stent", "height", "female", "diabetic", "acutemi", "ejecfrac", "ves1proc", "ps", "linps")``````{r}bal.wts1 <-dx.wts(x=lindner_clean_df$wts1, data=lindner_clean_df, vars=covlist, treat.var="treated", estimand="ATT")bal.wts1``````{r}bal.table(bal.wts1)``````{r}bal.before.wts1 <-bal.table(bal.wts1)[1]bal.after.wts1 <-bal.table(bal.wts1)[2]balance.att.weights <-tibble(names =rownames(bal.before.wts1$unw),pre.weighting =100*bal.before.wts1$unw$std.eff.sz,ATT.weighted =100*bal.after.wts1[[1]]$std.eff.sz)balance.att.weights <-gather(balance.att.weights, timing, szd, 2:3)```Now we can plot the standardized differences after ATT weighting.```{r}ggplot(balance.att.weights, aes(x = szd, y =reorder(names, szd), color = timing)) +geom_point() +geom_vline(xintercept =0) +geom_vline(xintercept =c(-10,10), linetype ="dashed", col ="blue") +labs(x ="Standardized Difference", y ="",title ="Standardized Difference before and after ATT Weighting")```The standardized differences look much better here in this approach.### Rubin's Rules#### Rule 1**Numbers from linps row in part [2] of balance table above**: (-0.048 * 100) = 4.8%. So passes Rule 1.#### Rule 2**Numbers from linps row in part [2] of balance table above**: (0.796^2^)/(0.839^2^) = 0.9001237. Passes Rule 2### Estimated effect on outcomes after ATT weighting#### Quantitative outcomeTo estimate the effect of the treatment on `cardbill`, we'll use svyglm from the `survey` package to apply the ATT weights in a linear model. ```{r}lindnerwt1.design <-svydesign(ids=~1, weights=~wts1, data=lindner_clean) # using ATT weightsadjout1.wt1 <-svyglm(cardbill ~ treated, design=lindnerwt1.design)wt_att_results1 <-tidy(adjout1.wt1, conf.int =TRUE) %>%filter(term =="treated")wt_att_results1 |>kable(digits =3)```**Estimate (95% CI)** `r round(wt_att_results1$estimate,2)` (`r round(wt_att_results1$conf.low,2)`, `r round(wt_att_results1$conf.high,2)`)#### Binary outcomeWe'll do similar coding for the binary outcome.```{r}adjout2.wt1 <-svyglm(sixMonthSurvive ~ treated, design=lindnerwt1.design, family=quasibinomial())wt_att_results2 <-tidy(adjout2.wt1, conf.int =TRUE, exponentiate =TRUE) %>%filter(term =="treated")wt_att_results2 |>kable(digits =3)```**Estimate (95% CI)** `r round(wt_att_results2$estimate,2)` (`r round(wt_att_results2$conf.low,2)`, `r round(wt_att_results2$conf.high,2)`)## Working with the ATE weightsNow, we'll go through the same steps with the ATE weights.```{r}ggplot(lindner_clean, aes(x = ps, y = wts2, color = treated_f)) +geom_point() +guides(color ="none") +facet_wrap(~ treated_f) +labs(x ="Estimated Propensity for Treatment",y ="ATE weights",title ="ATE weighting structure")``````{r}bal.wts2 <-dx.wts(x=lindner_clean_df$wts2, data=lindner_clean_df, vars=covlist,treat.var="treated", estimand="ATE")bal.wts2``````{r}bal.table(bal.wts2)``````{r}bal.before.wts2 <-bal.table(bal.wts2)[1]bal.after.wts2 <-bal.table(bal.wts2)[2]balance.ate.weights <-tibble(names =rownames(bal.before.wts2$unw),pre.weighting =100*bal.before.wts2$unw$std.eff.sz,ATE.weighted =100*bal.after.wts2[[1]]$std.eff.sz)balance.ate.weights <-gather(balance.ate.weights, timing, szd, 2:3)``````{r}ggplot(balance.ate.weights, aes(x = szd, y =reorder(names, szd), color = timing)) +geom_point() +geom_vline(xintercept =0) +geom_vline(xintercept =c(-10,10), linetype ="dashed", col ="blue") +labs(x ="Standardized Difference", y ="",title ="Standardized Difference before and after ATE Weighting")```Again, the standardized differences look good here.### Rubin's Rules#### Rule 1-0.034*100 = 3.4%. Passes Rule 1 (numbers from ATE weight balance table above).#### Rule 2(0.774^2^)/(0.815^2^) = 0.9019173. Passes Rule 2 (numbers from ATE weight balance table above).### Estimated effect on outcomes after ATE weighting#### Quantitative outcome```{r}lindnerwt2.design <-svydesign(ids=~1, weights=~wts2, data=lindner_clean) # using ATE weightsadjout1.wt2 <-svyglm(cardbill ~ treated, design=lindnerwt2.design)wt_ate_results1 <-tidy(adjout1.wt2, conf.int =TRUE) %>%filter(term =="treated")wt_ate_results1 |>kable(digits =3)```- **Estimate ** `r round(wt_ate_results1$estimate, 2)` (95% CI: `r round(wt_ate_results1$conf.low,2)`, `r round(wt_ate_results1$conf.high,2)`)#### Binary outcome```{r}adjout2.wt2 <-svyglm(sixMonthSurvive ~ treated, design=lindnerwt2.design, family=quasibinomial())wt_ate_results2 <-tidy(adjout2.wt2, conf.int =TRUE, exponentiate =TRUE) %>%filter(term =="treated")wt_ate_results2 |>kable(digits =3)```- **Estimate** `r round(wt_ate_results2$estimate,2)` (95% CI: `r round(wt_ate_results2$conf.low,2)`, `r round(wt_ate_results2$conf.high,2)`)# Task 10: Using TWANG for propensity score estimation and ATT weighting```{r}ps.toy <-ps(treated ~ stent + height + female + diabetic + acutemi + ejecfrac + ves1proc,data = lindner_clean_df,n.trees =3000,interaction.depth =2,stop.method =c("es.mean"),estimand ="ATT",verbose =FALSE)``````{r}plot(ps.toy)``````{r}summary(ps.toy)``````{r}plot(ps.toy, plots =2)``````{r}plot(ps.toy, plots =3)``````{r}bal.tab(ps.toy, full.stop.method ="es.mean.att")``````{r}p <-love.plot(bal.tab(ps.toy),threshold = .1, size =1.5,title ="Standardized Differences and TWANG ATT Weighting")p ```Compared to the manual ATT/ATE weights, the standardized differences look a bit worse here.## Estimated effect on outcomes after TWANG ATT weighting### Quantitative outcome```{r}toywt3.design <-svydesign(ids=~1,weights=~get.weights(ps.toy,stop.method ="es.mean"),data=lindner_clean) # using twang ATT weightsadjout1.wt3 <-svyglm(cardbill ~ treated, design=toywt3.design)wt_twangatt_results1 <-tidy(adjout1.wt3, conf.int =TRUE) %>%filter(term =="treated")wt_twangatt_results1 |>kable(digits =3)```- **Estimate** `r round(wt_twangatt_results1$estimate,2)` (95% CI: `r round(wt_twangatt_results1$conf.low,2)`, `r round(wt_twangatt_results1$conf.high,2)`)### Binary outcome```{r}adjout2.wt3 <-svyglm(sixMonthSurvive ~ treated, design=toywt3.design,family=quasibinomial())wt_twangatt_results2 <-tidy(adjout2.wt3, conf.int =TRUE, exponentiate =TRUE) %>%filter(term =="treated")wt_twangatt_results2 |>kable(digits =3)```- **Estimate** `r round(wt_twangatt_results2$estimate,2)` (95% CI: `r round(wt_twangatt_results2$conf.low,2)`, `r round(wt_twangatt_results2$conf.high,2)`)# Task 11: After direct adjustment with linear PSHere we'll directly adjust for the linear propensity score by including it as a covariate in the model.## Quantitative outcome```{r}direct_out1 <-lm(cardbill ~ treated + linps, data=lindner_clean)adj_out1 <-tidy(direct_out1, conf.int =TRUE) %>%filter(term =="treated")adj_out1 |>kable(digits =3)```- **Estimate** `r round(adj_out1$estimate,2)` (95% CI:`r round(adj_out1$conf.low,2)`, `r round(adj_out1$conf.high,2)`)## Binary outcome```{r}direct_out2 <-glm(sixMonthSurvive ~ treated + linps, data=lindner_clean, family=binomial())adj_out2 <-tidy(direct_out2, exponentiate =TRUE, conf.int =TRUE) %>%filter(term =="treated")adj_out2```- **Estimate** `r round(adj_out2$estimate,2)` (95% CI: `r round(adj_out2$conf.low,2)`, `r round(adj_out2$conf.high,2)`)# Task 12: "Double Robust" Approach: Weighting + Direct AdjustmentHere we'll adjust for the linear propensity score and the ATT/ATE/TWANG weights when predicting the quantitative outcome.## Quantitative outcome### ATT weights```{r}design_att <-svydesign(ids=~1, weights=~wts1, data=lindner_clean) # using ATT weightsdr.out1.wt1 <-svyglm(cardbill ~ treated + linps, design=design_att)dr_att_out1 <-tidy(dr.out1.wt1, conf.int =TRUE) %>%filter(term =="treated")dr_att_out1 |>kable(digits =3)```- **Estimate** `r round(dr_att_out1$estimate,2)` (95% CI: `r round(dr_att_out1$conf.low,2)`, `r round(dr_att_out1$conf.high,2)`)### ATE weights```{r}design_ate<-svydesign(ids=~1, weights=~wts2, data=lindner_clean) # using ATE weightsdr.out1.wt2 <-svyglm(cardbill ~ treated + linps, design=design_ate)dr_ate_out1 <-tidy(dr.out1.wt2, conf.int =TRUE) %>%filter(term =="treated")dr_ate_out1 |>kable(digits =3)```- **Estimate** `r round(dr_ate_out1$estimate,2)` (95% CI: `r round(dr_ate_out1$conf.low,2)`, `r round(dr_ate_out1$conf.high,2)`)### TWANG ATT weights```{r}wts3 <-get.weights(ps.toy, stop.method ="es.mean")twang.design <-svydesign(ids=~1, weights=~wts3, data=lindner_clean) # twang ATT weightsdr.out1.wt3 <-svyglm(cardbill ~ treated + linps, design=twang.design)dr_twangatt_out1 <-tidy(dr.out1.wt3, conf.int =TRUE) %>%filter(term =="treated")dr_twangatt_out1 |>kable(digits =3)```- **Estimate** `r round(dr_twangatt_out1$estimate,2)` (95% CI: `r round(dr_twangatt_out1$conf.low,2)`, `r round(dr_twangatt_out1$conf.high,2)`)## Binary outcomeNow we'll adjust for the linear propensity score and the ATT/ATE/TWANG weights when predicting the binary outcome.### ATT weights```{r}dr.out2.wt1 <-svyglm(sixMonthSurvive ~ treated + linps, design=design_att,family=quasibinomial())dr_att_out2 <-tidy(dr.out2.wt1, exponentiate =TRUE, conf.int =TRUE) %>%filter(term =="treated")dr_att_out2 |>kable(digits =3)```- **Estimate** `r round(dr_att_out2$estimate,2)` (95% CI: `r round(dr_att_out2$conf.low,2)`, `r round(dr_att_out2$conf.high,2)`)### ATE weights```{r}dr.out2.wt2 <-svyglm(sixMonthSurvive ~ treated + linps, design=design_ate,family=quasibinomial())dr_ate_out2 <-tidy(dr.out2.wt2, exponentiate =TRUE, conf.int =TRUE) %>%filter(term =="treated")dr_ate_out2 |>kable(digits =3)```- **Estimate** `r round(dr_ate_out2$estimate,2)` (95% CI: `r round(dr_ate_out2$conf.low,2)`, `r round(dr_ate_out2$conf.high,2)`)### TWANG ATT weights```{r}dr.out2.wt3 <-svyglm(sixMonthSurvive ~ treated + linps, design=twang.design,family=quasibinomial())dr_twangatt_out2 <-tidy(dr.out2.wt3, exponentiate =TRUE, conf.int =TRUE) %>%filter(term =="treated")dr_twangatt_out2 |>kable(digits =3)```- **Estimate** `r round(dr_twangatt_out2$estimate,2)` (95% CI: `r round(dr_twangatt_out2$conf.low,2)`, `r round(dr_twangatt_out2$conf.high,2)`)# Session Information```{r}xfun::session_info()```