This document provides a step-by-step guide for implementation of matching weight method in practice. The example is in the three-group setting. However, the essentially the same code can be used in the two-group setting or settings where there are more than three groups. The example is written in R, but it can be implemented in any statistical environment that has (multinomial) logistic regression and weighted data analysis capabilities.
The tutoring dataset included in the TriMatch R package is used. The exposure is the treat variable, which takes one of Treat1, Treat2, and Control. These represent the tutoring method each student received. The outcome is the Grade ordinal variable, which takes one of 0, 1, 2, 3, or 4. Pre-treatment potential confounders include gender, ethnicity, military service status of the student, non-native English speaker status, education level of the subject’s mother (ordinal), education level of the subject’s father (ordinal), age of the student, employment status (no, part-time, full-time), household income (ordinal), number of transfer credits, grade point average. The dataset does not contain any missing values. See ?tutoring for details. The employment categorical variable is coded numerically. Thus, it is converted to a factor.
## Load data
library(TriMatch)
data(tutoring)
summary(tutoring)
## treat Course Grade Gender Ethnicity Military ESL
## Control:918 Length:1142 Min. :0.000 FEMALE:627 Black:211 Mode :logical Mode :logical
## Treat1 :134 Class :character 1st Qu.:2.000 MALE :515 Other:193 FALSE:783 FALSE:1049
## Treat2 : 90 Mode :character Median :4.000 White:738 TRUE :359 TRUE :93
## Mean :2.891 NA's :0 NA's :0
## 3rd Qu.:4.000
## Max. :4.000
## EdMother EdFather Age Employment Income Transfer GPA
## Min. :1.000 Min. :1.000 Min. :20.00 Min. :1.000 Min. :1.000 Min. : 3.00 Min. :0.000
## 1st Qu.:3.000 1st Qu.:3.000 1st Qu.:30.00 1st Qu.:3.000 1st Qu.:3.000 1st Qu.: 36.66 1st Qu.:2.890
## Median :3.000 Median :3.000 Median :37.00 Median :3.000 Median :5.000 Median : 48.31 Median :3.215
## Mean :3.785 Mean :3.684 Mean :36.92 Mean :2.667 Mean :5.059 Mean : 52.12 Mean :3.166
## 3rd Qu.:5.000 3rd Qu.:5.000 3rd Qu.:43.00 3rd Qu.:3.000 3rd Qu.:7.000 3rd Qu.: 65.00 3rd Qu.:3.518
## Max. :8.000 Max. :9.000 Max. :65.00 Max. :3.000 Max. :9.000 Max. :126.00 Max. :4.000
## GradeCode Level ID
## Length:1142 Lower:988 Min. : 1.0
## Class :character Upper:154 1st Qu.: 286.2
## Mode :character Median : 571.5
## Mean : 571.5
## 3rd Qu.: 856.8
## Max. :1142.0
## Make employment categorical
tutoring$Employment <- factor(tutoring$Employment, levels = 1:3,
labels = c("no","part-time","full-time"))
The tableone package can be utilized for covariate balance assessment using standardized mean differences (SMD). SMD greater than 0.1 is often regarded as a substantial imbalance. The SMD shown in the table is the average of all possible pairwise SMDs.
## Examine covariate balance
library(tableone)
covariates <- c("Gender", "Ethnicity", "Military", "ESL",
"EdMother", "EdFather", "Age", "Employment",
"Income", "Transfer", "GPA")
tab1Unadj <- CreateTableOne(vars = covariates, strata = "treat", data = tutoring)
print(tab1Unadj, test = FALSE, smd = TRUE)
## Stratified by treat
## Control Treat1 Treat2 SMD
## n 918 134 90
## Gender = MALE (%) 449 (48.9) 38 (28.4) 28 (31.1) 0.287
## Ethnicity (%) 0.095
## Black 166 (18.1) 24 (17.9) 21 (23.3)
## Other 157 (17.1) 23 (17.2) 13 (14.4)
## White 595 (64.8) 87 (64.9) 56 (62.2)
## Military = TRUE (%) 309 (33.7) 32 (23.9) 18 (20.0) 0.208
## ESL = TRUE (%) 76 ( 8.3) 8 ( 6.0) 9 (10.0) 0.100
## EdMother (mean (sd)) 3.80 (1.49) 3.78 (1.51) 3.67 (1.54) 0.057
## EdFather (mean (sd)) 3.68 (1.65) 3.66 (1.73) 3.78 (1.73) 0.044
## Age (mean (sd)) 36.75 (8.95) 37.10 (9.41) 38.41 (9.49) 0.119
## Employment (%) 0.248
## no 95 (10.3) 24 (17.9) 18 (20.0)
## part-time 75 ( 8.2) 20 (14.9) 11 (12.2)
## full-time 748 (81.5) 90 (67.2) 61 (67.8)
## Income (mean (sd)) 5.10 (2.24) 5.04 (2.60) 4.69 (2.51) 0.111
## Transfer (mean (sd)) 51.40 (24.38) 57.37 (25.10) 51.61 (26.39) 0.158
## GPA (mean (sd)) 3.16 (0.58) 3.16 (0.46) 3.24 (0.58) 0.097
## Examine all pairwise SMDs
ExtractSmd(tab1Unadj)
## average 1 vs 2 1 vs 3 2 vs 3
## Gender 0.28718081 0.431825669 0.369462797 0.06025398
## Ethnicity 0.09475231 0.004540496 0.137619463 0.14209699
## Military 0.20773590 0.217301900 0.312032587 0.09387322
## ESL 0.09955894 0.089842245 0.059753148 0.14908142
## EdMother 0.05735067 0.014182489 0.086066827 0.07180268
## EdFather 0.04433253 0.007919139 0.059274560 0.06580389
## Age 0.11889003 0.038429226 0.179969129 0.13827175
## Employment 0.24838203 0.332479394 0.324590337 0.08807636
## Income 0.11113230 0.025003114 0.171951403 0.13644238
## Transfer 0.15777889 0.241327245 0.008454888 0.22355453
## GPA 0.09651297 0.009213587 0.128230886 0.15209444
As the exposure is a three-category variable, the propensity score model can be modeled using multinomial logistic regression. In R, the VGAM (vector generalized linear and additive models) package provides a flexible framework for this. Because the sample size of the treatment 2 group is small, making flexible modeling difficult, the ordinal variables are used only as linear terms. Predicting the “response” gives predicted probabilities of each treatment as a (sample size) \(\times\) 3 matrix, which then can be added to the dataset. The following AddGPS function can be used to ease this process. Three propensity scores (one for each treatment category) are added to the dataset.
## Function to add generalized PS to dataset
AddGPS <- function(data, formula, family = multinomial(), psPrefix = "PS_") {
library(VGAM)
## Fit multinomial logistic regression
resVglm <- vglm(formula = formula, data = data, family = family)
## Calculate PS
psData <- as.data.frame(predict(resVglm, type = "response"))
names(psData) <- paste0(psPrefix, names(psData))
cbind(data, psData)
}
tutoring <- AddGPS(data = tutoring, # dataset
## Propensity score model for multinomial regression
formula = treat ~ Gender + Ethnicity + Military +
ESL + EdMother + EdFather + Age +
Employment + Income + Transfer + GPA)
As mentioned in the manuscript, the matching weight is defined as follows.
\[\begin{align*} MW_{i} &= \frac{{Smallest~ PS}}{{PS~ of~ assigned~ treatment}}\\ &= \frac{{min}(e_{1i},...,e_{Ki})}{\sum^K_{k=1} I(Z_{i} = k) e_{ki}}\\ \end{align*}\]where \(e_{ki}\) is the \(i\)-th individual’s probability of being assigned to the \(k\)-th treatment category given the covariate pattern, \(Z_i \in \{1,...,K\}\) is the categorical variable indicating the \(i\)-th individual’s treatment assignment.
The following function can be used to add matching weight to the dataset. Individuals’ matching weights have a range of [0,1], where as the inverse probability treatment weights have a range of [1,\(\infty\)].
## Function to add matching weight as mw to dataset
AddMwToData <- function(data, txVar, txLevels, psPrefix = "PS_") {
## Treatment indicator data frame (any number of groups allowed)
dfAssign <- as.data.frame(lapply(txLevels, function(tx_k) {
as.numeric(data[txVar] == tx_k)
}))
## Name of PS variables
psVars <- paste0(psPrefix, txLevels)
## Pick denominator (PS for assigned treatment)
data$PS_assign <- rowSums(data[psVars] * dfAssign)
## Pick numerator
data$PS_min <- do.call(pmin, data[psVars])
## Calculate the IPTW
data$iptw <- 1 / data$PS_assign
## Calculate the matching weight
data$mw <- exp(log(data$PS_min) - log(data$PS_assign))
## Return the whole data
data
}
## Add IPTW and MW
tutoring <- AddMwToData(data = tutoring, # dataset
txVar = "treat", # treatment variable name
tx = c("Control", "Treat1", "Treat2")) # treatment levels
## Check how weights are defined
head(tutoring[c("treat","PS_Control","PS_Treat1","PS_Treat2","PS_assign","PS_min","iptw","mw")], 20)
## treat PS_Control PS_Treat1 PS_Treat2 PS_assign PS_min iptw mw
## 3 Control 0.8192816 0.11440448 0.06631388 0.81928164 0.06631388 1.220581 0.08094149
## 4 Control 0.8313205 0.10516348 0.06351606 0.83132046 0.06351606 1.202906 0.07640383
## 11 Control 0.6346235 0.22597339 0.13940309 0.63462352 0.13940309 1.575737 0.21966266
## 12 Control 0.7203265 0.11853269 0.16114082 0.72032649 0.11853269 1.388259 0.16455412
## 14 Control 0.6759314 0.15931947 0.16474916 0.67593137 0.15931947 1.479440 0.23570361
## 16 Treat1 0.7278386 0.18054526 0.09161616 0.18054526 0.09161616 5.538777 0.50744155
## 17 Control 0.7963014 0.09228518 0.11141339 0.79630143 0.09228518 1.255806 0.11589227
## 18 Control 0.7963014 0.09228518 0.11141339 0.79630143 0.09228518 1.255806 0.11589227
## 19 Control 0.4011609 0.29293705 0.30590201 0.40116094 0.29293705 2.492765 0.73022327
## 23 Control 0.7980564 0.14170696 0.06023666 0.79805638 0.06023666 1.253044 0.07547920
## 28 Treat2 0.7696177 0.11208565 0.11829667 0.11829667 0.11208565 8.453323 0.94749620
## 31 Treat1 0.7876534 0.11912070 0.09322587 0.11912070 0.09322587 8.394847 0.78261688
## 32 Control 0.7602112 0.13218394 0.10760486 0.76021120 0.10760486 1.315424 0.14154600
## 34 Treat2 0.6994628 0.12694918 0.17358797 0.17358797 0.12694918 5.760768 0.73132478
## 38 Treat1 0.6359332 0.24401948 0.12004734 0.24401948 0.12004734 4.098034 0.49195804
## 39 Control 0.7523881 0.15006473 0.09754713 0.75238814 0.09754713 1.329101 0.12965001
## 40 Control 0.8281320 0.11921012 0.05265789 0.82813199 0.05265789 1.207537 0.06358635
## 49 Treat1 0.7963180 0.09950924 0.10417277 0.09950924 0.09950924 10.049318 1.00000000
## 50 Control 0.8929612 0.06199434 0.04504442 0.89296124 0.04504442 1.119869 0.05044387
## 51 Control 0.6910650 0.16455995 0.14437500 0.69106505 0.14437500 1.447042 0.20891666
## Check weight distribution
summary(tutoring[c("mw","iptw")])
## mw iptw
## Min. :0.01025 Min. : 1.052
## 1st Qu.:0.05546 1st Qu.: 1.154
## Median :0.09410 Median : 1.258
## Mean :0.21706 Mean : 3.066
## 3rd Qu.:0.17721 3rd Qu.: 1.465
## Max. :1.00000 Max. :46.446
All analyses afterward should be proceeded as weighted analyses. In R, this is most easily achieved by using the survey package. Firstly, a survey design object must be created with svydesign function. The resulting object is then used as the dataset. The weighted covariate table can be constructed with the tableone package. All SMDs are less than 0.1 after weighting, indicating better covariate balance.
## Created weighted data object
library(survey)
tutoringSvy <- svydesign(ids = ~ 1, data = tutoring, weights = ~ mw)
## Weighted table with tableone
tab1Mw <- svyCreateTableOne(vars = covariates, strata = "treat", data = tutoringSvy)
print(tab1Mw, test = FALSE, smd = TRUE)
## Stratified by treat
## Control Treat1 Treat2 SMD
## n 82.8 82.6 82.5
## Gender = MALE (%) 24.9 (30.1) 25.0 (30.3) 24.4 (29.6) 0.010
## Ethnicity (%) 0.010
## Black 18.9 (22.9) 19.2 (23.3) 18.8 (22.8)
## Other 11.7 (14.1) 11.3 (13.7) 11.6 (14.1)
## White 52.2 (63.0) 52.1 (63.1) 52.0 (63.0)
## Military = TRUE (%) 17.2 (20.8) 19.7 (23.8) 17.4 (21.1) 0.048
## ESL = TRUE (%) 6.1 ( 7.4) 6.4 ( 7.7) 8.1 ( 9.8) 0.056
## EdMother (mean (sd)) 3.66 (1.49) 3.65 (1.47) 3.65 (1.55) 0.006
## EdFather (mean (sd)) 3.71 (1.70) 3.66 (1.75) 3.73 (1.70) 0.024
## Age (mean (sd)) 38.13 (9.68) 38.21 (9.63) 38.01 (9.38) 0.014
## Employment (%) 0.041
## no 16.3 (19.7) 15.6 (18.9) 15.2 (18.4)
## part-time 10.2 (12.3) 9.2 (11.2) 10.5 (12.7)
## full-time 56.3 (68.0) 57.7 (69.9) 56.8 (68.9)
## Income (mean (sd)) 4.76 (2.35) 4.72 (2.47) 4.80 (2.47) 0.023
## Transfer (mean (sd)) 52.46 (24.04) 51.39 (25.02) 53.48 (26.19) 0.055
## GPA (mean (sd)) 3.21 (0.49) 3.21 (0.45) 3.21 (0.59) 0.004
## All pairwise SMDs
ExtractSmd(tab1Mw)
## average 1 vs 2 1 vs 3 2 vs 3
## Gender 0.010336859 0.004393687 0.0111115330 0.0155053556
## Ethnicity 0.009595945 0.013881066 0.0006174629 0.0142893048
## Military 0.047738733 0.071609306 0.0067821033 0.0648247896
## ESL 0.055666107 0.010019487 0.0834804231 0.0734984115
## EdMother 0.005765913 0.008755059 0.0082762793 0.0002663992
## EdFather 0.023721214 0.024874520 0.0107632204 0.0355259006
## Age 0.013982735 0.008033386 0.0128645704 0.0210502478
## Employment 0.040896810 0.043102022 0.0330741322 0.0465142771
## Income 0.023351441 0.019691181 0.0157469189 0.0346162234
## Transfer 0.055073782 0.043293809 0.0406028456 0.0813246930
## GPA 0.003834104 0.006104611 0.0018132523 0.0035844491
Visualizing the covariate balance before and after weighting can sometimes be helpful. Extracted SMD data can be fed to a plotting function (here ggplot2).
## Create SMD data frame
dataPlot <- data.frame(variable = rownames(ExtractSmd(tab1Unadj)),
Unadjusted = ExtractSmd(tab1Unadj)[,"average"],
Weighted = ExtractSmd(tab1Mw)[,"average"])
## Reshape to long format
library(reshape2)
dataPlotMelt <- melt(data = dataPlot,
id.vars = "variable",
variable.name = "method",
value.name = "SMD")
## Variables names ordered by unadjusted SMD values
varsOrderedBySmd <- rownames(dataPlot)[order(dataPlot[,"Unadjusted"])]
## Reorder factor levels
dataPlotMelt$variable <- factor(dataPlotMelt$variable,
levels = varsOrderedBySmd)
dataPlotMelt$method <- factor(dataPlotMelt$method,
levels = c("Weighted","Unadjusted"))
## Plot
library(ggplot2)
ggplot(data = dataPlotMelt, mapping = aes(x = variable, y = SMD, group = method, linetype = method)) +
geom_line() +
geom_point() +
geom_hline(yintercept = 0, size = 0.3) +
geom_hline(yintercept = 0.1, size = 0.1) +
coord_flip() +
theme_bw() + theme(legend.key = element_blank())
The outcome analyses should also be proceeded as weighted analyses. Most functions in the survey package is named svy* with * being the name of the unweighted counterpart.
The outcome was handled as a continuous outcome for simplicity. In weighted linear regression, both treatments appear superior to the control without tutoring regarding the course grade assuming the propensity score model was correctly specified. The mean difference was 0.45 [0.23, 0.67] for treatment 1 vs control and 0.67 [0.45, 0.89] for treatment 2 vs control.
## Weighted group means of Grade
svyby(formula = ~ Grade, by = ~ treat, design = tutoringSvy, FUN = svymean)
## treat Grade se
## Control Control 2.792759 0.06648740
## Treat1 Treat1 3.244832 0.09179853
## Treat2 Treat2 3.463329 0.09070431
## Group difference tested in weighted regression
modelOutcome1 <- svyglm(formula = Grade ~ treat, design = tutoringSvy)
summary(modelOutcome1)
##
## Call:
## svyglm(formula = Grade ~ treat, design = tutoringSvy)
##
## Survey design:
## svydesign(ids = ~1, data = tutoring, weights = ~mw)
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 2.79276 0.06649 42.004 < 2e-16 ***
## treatTreat1 0.45207 0.11335 3.988 0.00007076303 ***
## treatTreat2 0.67057 0.11246 5.963 0.00000000331 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for gaussian family taken to be 1.394533)
##
## Number of Fisher Scoring iterations: 2
## ShowRegTable in tableone may come in handy
ShowRegTable(modelOutcome1, exp = FALSE)
## coef [confint] p
## (Intercept) 2.79 [2.66, 2.92] <0.001
## treatTreat1 0.45 [0.23, 0.67] <0.001
## treatTreat2 0.67 [0.45, 0.89] <0.001
As discussed in the manuscript, bootstrapping may provide better variance estimates than model-based inference. The boot package is a general purpose bootstrapping package. The following context-specific wrapper functions can be used to simplify the process. In this specific example, the bootstrap confidence intervals for the treatment effects were somewhat narrower.
## Define a function for each bootstrap step
BootModelsConstructor <- function(formulaPs, formulaOutcome, OutcomeRegFun, ...) {
## Obtain treatment variable name
txVar <- as.character(formulaPs[[2]])
## Return a function
function(data, i) {
## Obtain treatment levels
txLevels <- names(table(data[,txVar]))
## Add generalized propensity scores
dataB <- AddGPS(data = data[i,], formula = formulaPs)
## Add matching weight
dataB <- AddMwToData(data = dataB, txVar = txVar, txLevels = txLevels)
## Weighted analysis (lm() ok as only the estimates are used)
lmWeighted <- OutcomeRegFun(formula = formulaOutcome, data = dataB,
weights = mw, ...)
## Extract coefs
coef(lmWeighted)
}
}
## Define a function to summarize bootstrapping
BootSummarize <- function(data, R, BootModels, level = 0.95, ...) {
## Use boot library
library(boot)
## Run bootstrapping
outBoot <- boot(data = data, statistic = BootModels, R = R, ...)
out <- outBoot$t
colnames(out) <- names(outBoot$t0)
## Confidence intervals
lower <- apply(out, MARGIN = 2, quantile, probs = (1 - level) / 2)
upper <- apply(out, MARGIN = 2, quantile, probs = (1 - level) / 2 + level)
outCi <- cbind(lower = lower, upper = upper)
## Variance of estimator
outVar <- apply(out, MARGIN = 2, var)
outSe <- sqrt(outVar)
## Return as a readable table
cbind(est = outBoot$t0, outCi, var = outVar, se = outSe)
}
## Construct a custom bootstrap function with specific formulae
## formulaPs is propensity score model
BootModels <- BootModelsConstructor(formulaPs = treat ~ Gender + Ethnicity + Military +
ESL + EdMother + EdFather + Age +
Employment + Income + Transfer + GPA,
## Outcome model
formulaOutcome = Grade ~ treat,
## Regression function for outcome model
OutcomeRegFun = lm)
## Use a clean dataset without PS and weight variables
data(tutoring)
## Make employment categorical
tutoring$Employment <- factor(tutoring$Employment, levels = 1:3,
labels = c("no","part-time","full-time"))
## Run bootstrap
set.seed(201508131)
system.time(bootOut1 <- BootSummarize(data = tutoring, R = 2000, BootModels = BootModels))
## user system elapsed
## 228.096 6.023 251.345
bootOut1
## est lower upper var se
## (Intercept) 2.7927593 2.6130814 2.9872607 0.008972568 0.09472364
## treatTreat1 0.4520730 0.2325361 0.6577786 0.011831058 0.10877067
## treatTreat2 0.6705692 0.4626595 0.8484488 0.009776627 0.09887683
## Show naive confidence interval again
ShowRegTable(modelOutcome1, exp = FALSE, digits = 7)
## coef [confint] p
## (Intercept) 2.7927593 [2.6624464, 2.9230722] <0.001
## treatTreat1 0.4520730 [0.2299169, 0.6742290] <0.001
## treatTreat2 0.6705692 [0.4501465, 0.8909920] <0.001