Coarsened Exact Matching in R. Causal Inference with MatchIt | by Zolzaya Luvsandorj | Towards Data Science

Haven’t we all heard the phrase ‘Correlation doesn’t imply causation’ and wondered ‘Then, what does’? Well, causal inference. Causal inference allows us to make conclusions about causal impact which can empower important decisions and provide valuable data-driven insights. Therefore, knowing the basics of causal inference is a great investment for data scientists and analysts. Coarsened Exact Matching (CEM) is a simple and intuitive way to conduct a causal inference from an observational data. This post shows the basic intuition behind CEM with an example using MatchIt library in R.
Imagine we wanted to understand the causal impact of a treatment on an outcome of interest. The simplest thing we could do is to compare the average outcome by the treated vs untreated groups. However, this comparison may not give us an accurate estimate because the profile of treated and untreated groups tend to be not comparable even in the absence of the treatment. One way to get around this issue is try to make the two groups comparable. This is where matching techniques such as CEM come in.
Before we start discussing CEM, let’s start with a matching technique called Exact Matching (EM). For EM, we match treated and untreated records that share the exact same values in their covariates. In other words, these matched records will have identical characteristics except for their treatment status. Once we have done all possible matches and discarded the unmatched records, then the treated vs untreated groups in the matched data will now be more comparable and we can analyse the difference in the group means to infer the causal impact.
However, many records can be excluded from the analysis due to not having a match when doing EM. For instance, it will be very hard to find an exact match if you have many numerical covariates. This is where CEM provides a great alternative. When doing CEM, there are three mains steps:
Coarsen the data to reduce the level of granularity. This means binning numerical values and/or grouping categorical values.Apply an exact matching on the coarsened data to find comparable control and treatment groups. This means finding all combinations of the covariates that have at least one control and one treatment record and keep records that belong to the combinations and drop the rest. Each combination is referred to as stratum.Estimate the causal impact using the matched data.
Coarsen the data to reduce the level of granularity. This means binning numerical values and/or grouping categorical values.
Apply an exact matching on the coarsened data to find comparable control and treatment groups. This means finding all combinations of the covariates that have at least one control and one treatment record and keep records that belong to the combinations and drop the rest. Each combination is referred to as stratum.
Estimate the causal impact using the matched data.
Having learned the basic intuition behind CEM, we can see how informative its name is. Let’s look at a simple example to better understand the technique.
Let’s import necessary libraries and load dataset. We will use a small subset of Lalonde dataset to keep things manageable and easy to monitor.
library("dplyr")library("MatchIt")library("lmtest")library("sandwich")options(scipen = 100, digits=4)# Prepare sample data --------------------data("lalonde")set.seed(42)(df <- lalonde %>%     select(treat, educ, race, re78) %>%     sample_n(30))
We have the following variables for 30 randomly selected records:◼️ Covariates: educ for years of schooling and race◼️ Treatment: treat for treatment status◼️ Outcome: re78 for real earnings in 1978Let’s assume we wanted to understand the causal impact of treat on re78.
We can perform CEM with matchit by specifying method = 'cem’:
# Perform CEM --------------------matching1 <- matchit(treat ~ educ + race, data = df,                      method = 'cem', estimand = 'ATE')summary(matching1, un=FALSE)
We can see that 22 control records were available but only 5 of them were matched and the remaining 17 were unmatched and are going to be dropped from the analysis. The effective sample size for the matched records (Matched (ESS)) was 4.91 for the control group. The same summary is also available for the treatment group.
Now, let’s look at the matched data for the 12 matched records:
# Extract matched data --------------------(matched_df1 <- match.data(matching1) %>% arrange(subclass, treat))
There are 3 stratums shown by subclass. While we don’t see the coarsened data here, we can roughly guess the level of coarsening from the original values. For instance, subclass 3 seems to contain records with 9–10 years of education and black race. We can see that weights are added for each record. Let’s understand how these weights are calculated.
Each stratum has two weights: one weight for the treatment records in the stratum and the other for the control records. When calculating weights, we want to ensure two things:◼️ The proportion of treatment and control groups at an overall level are maintained within each stratum after weighting:
◼️ Weighted counts equals to the unweighted count within stratum:
This formula can be rearranged and simplified to the following depending on the type of estimand we are interested in:
🔷 Average treatment effect on the treated (ATT) - Treatment records are unweighted (i.e. weight of 1) and control records get weighted:
🔷 Average treatment effect on the control (ATC) - The same logic is true for ATC. Control records are unweighted and treatment records are weighted:
🔷 Average treatment effect (ATE) - Both groups are weighted:
If you want to learn about ATT, ATC, ATE, check out section 1.5 in this post.
Earlier we specified that we are interested to know ATE. Using the formula, let’s find the weight for subclass 1 as an example:
We just calculated the weights for records PSID376 and NSW153 ourselves to get the same weights that the function returned. If you are keen, calculate the weights for the other two classes to consolidate your understanding.
We have seen Matched (ESS) earlier. In case you are interested in finding out how it is calculated, here’s the formula:
As an example, let’s calculate ESS for the matched control group:
Having matched the records, it’s time to look at the estimated ATE:
# Estimate causal impact - look at ATE --------------------model1 <- lm(re78 ~ treat, data = matched_df1, weights = weights)coeftest(model1, vcov. = vcovCL, cluster = ~subclass)
Here, we can see the estimate of 4907 and its statistical significance. This estimate is the difference between the weighted means between the groups:
matched_df1 %>% group_by(treat) %>%  summarise(w_mean = weighted.mean(re78, weights))
We can also check out the confidence interval of the estimate:
coefci(model1, vcov. = vcovCL, cluster = ~subclass)
The 95% confidence interval is -1073 to 10887. These outputs suggest that treatment doesn’t have statistically significant impact on the output. This is not too surprising as we are working with very small toy dataset.
So far we have let the function use its default level of coarsening: binning numerical variables using Sturges method and keeping the categorical variables as is. However, in practice, it would be useful if we could control the level of coarsening. We will now learn to use custom cut points for numerical variables and custom groupings for categorical variables.
While applying CEM, some records are dropped out in order to achieve balance (i.e. make groups comparable), but dropping too many records is not desirable. In our first matching, more than half of the records (i.e. 18 out of 30) were dropped out. Let’s try to reduce the number of excluded records while trying to map similar records in each stratum.
# Perform CEM with custom coarsening --------------------cutpoints <- list(educ = c(8.5, 10.5))grouping <- list(race = list(c("white", "black"), c("hispan")))matching2 <- matchit(treat ~ educ + race, data = df,                      method = 'cem', estimand = 'ATE',                     cutpoints=cutpoints, grouping=grouping)summary(matching2, un=FALSE)
We used two cut off points to create three bins for educ and grouped non-Hispanic races together. By making the data more coarse, we have reduced the number of unmatched records from 18 to 3. However, this comes at a risk of including less similar observations in the same stratum where observations are less comparable. This means we need to find the fine balance between not excluding too many records and not including too dissimilar records in the stratum. Let’s look at the matched data to evaluate how similar the records are in each stratum:
# Extract matched data(matched_df2 <- match.data(matching2) %>% arrange(subclass, treat))
Do the records within the subclass look comparable to each other? This is something we need to evaluate and reiterate if necessary until we are satisfied. Let’s imagine we were happy, we can proceed to inspecting the estimate:
# Estimate causal impact - look at ATE --------------------model2 <- lm(re78 ~ treat, data = matched_df2, weights = weights)coeftest(model2, vcov. = vcovCL, cluster = ~subclass)
coefci(model2, vcov. = vcovCL, cluster = ~subclass)
After looking at this output, conclusions from the previous analysis remain unchanged.
In this introductory post, we have learned the basics of CEM: how the algorithm works, how to control the level of coarsening and how to analyse the causal impact. For keen learners, this and this are awesome additional resources to look into. I hope you get to use this technique to provide valuable insights to your stakeholders. ✨
Would you like to access more content like this? Medium members get unlimited access to any articles on Medium. If you become a member using my referral link, a portion of your membership fee will directly go to support me.
Thank you for reading this article. If you are interested, here are links to some of my other posts:
◼️ Propensity score matching (Causal inference)◼️ Explaining Scikit-learn models with SHAP◼️️ K-Nearest Neighbours explained◼️️ Logistic regression explained◼️️ Comparing Random Forest and Gradient Boosting◼️️ How are decision trees built?◼️️ Pipeline, ColumnTransformer and FeatureUnion explained
Bye for now 🏃 💨