Companion function to etwfe, enabling the recovery of aggregate treatment
effects along different dimensions of interest (e.g, an event study of
dynamic average treatment effects). emfx is a light wrapper around the
slopes function from the marginaleffects
package. If the original etwfe model was estimated with regression weights
(e.g., weights = ~pop), these are also used to weight the aggregation.
Arguments
- object
An
etwfemodel object.- type
Character. The desired type of post-estimation aggregation.
- by_xvar
Logical. Should the results account for heterogeneous treatment effects? Only relevant if the preceding
etwfecall included a specifiedxvarargument, i.e. interacted categorical covariate. The default behaviour ("auto") is to automatically estimate heterogeneous treatment effects for each level ofxvarif these are detected as part of the underlyingetwfemodel object. Users can override by setting to eitherFALSEorTRUE.See the "Heterogeneous treatment effects" section below.- compress
Logical. Compress the data by (period by cohort) groups before calculating marginal effects? This legacy argument was originally intended as a shortcut to reduce estimation times for large datasets. However, it is largely irrelevant as of etwfe v0.7.0, which introduced fast analytic standard errors (see the "Standard errors" section below). The default
"auto"is retained for backwards compatibility and is equivalent toFALSE. Note that switching toTRUEalso means losing the default unconditional standard errors, since these are not supported with data compression.- collapse
Logical. An alias for
compress(only used for backwards compatability and ignored if both arguments are provided). The behaviour is identical, but it will trigger a message nudging users to rather use thecompressargument.- predict
Character. The type (scale) of prediction used to compute the marginal effects. If
"response"(the default), then the output is at the level of the response variable, i.e. it is the expected predictor \(E(Y|X)\). If"link", the value returned is the linear predictor of the fitted model, i.e. \(X\cdot \beta\). The difference should only matter for nonlinear models. (Note: This argument is typically calledtypewhen use inpredictorslopes, but we rename it here to avoid a clash with the top-leveltypeargument above.)- wlevels
Only relevant for non-binary treatments, i.e. if the preceding
etwfecall included awvarargument. Controls the treatment level(s) at which effects are evaluated:NULL (default): Equivalent to
"slope", except for event studies with the"never"treated control group, where it is equivalent to"observed"(since the pre-treatment estimates are level differences)."slope": Average marginal effect of a one-unit change inwvar."observed": Average effect of each unit's observed treatment level, relative to no treatment (wvar = 0). This also reflects how treatment levels evolve over time, and is the closest analogue to a binary ATT.Numeric vector: Effects of setting the treatment level to each of the supplied values, relative to no treatment, e.g.
wlevels = c(1, 2, 5). Results are reported (and plotted) separately for each treatment level.Function: As above, but with treatment levels computed by applying the function to the treated observations'
wvarvalues, e.g.wlevels = fivenumorwlevels = \(x) quantile(x, c(0.25, 0.5, 0.75)).
These options mirror the ergonomics of
datagrid, which similarly accepts values or functions (e.g.,datagrid(x = fivenum)). Note that effects at fixed treatment levels are linear inwvarby construction, and that levels far from the bulk of the data (e.g., the minimum and maximum infivenum) are extrapolations.For event studies with the
"never"treated control group, the pre-treatment estimates do not depend onwvar, and are reported only once (with a missingwvarvalue, and as a "Pre" group in plots).- post_only
Logical. Drop pre-treatment ATTs? Only evaluated if (a)
type = "event"and (b) the originaletwfemodel object was estimated using the default"notyet"treated control group. If conditions (a) and (b) are met then the pre-treatment effects will be zero as a mechanical result of ETWFE's estimation setup. The default behaviour (TRUE) is thus to drop these nuisance rows from the dataset. Thepost_onlyargument recognises that you may still want to keep them for presentation purposes (e.g., plotting an event study). Nevertheless, be forewarned that enabling that behaviour viaFALSEis strictly performative: the "zero" treatment effects for any pre-treatment periods is purely an artefact of the estimation setup.- window
Numeric of length 1 or 2. Limits the temporal window of consideration around treatment.
NULL (default): Include all available periods.
Length 1: Truncate to a symmetric window around the treatment event. E.g.,
window = 2will truncate to two pre-treatment periods and two post-treatment periods.Length 2: Asymmetric window, where the first number gives the maximum number of pre-treatment periods and the second number gives the maximum number of post-treatment periods. E.g.,
window = c(5, 2)will truncate to five pre-treatment periods and two post-treatment periods.
Note that the pre-treatment truncation is only ever binding in cases where the "never" treated group is used as a control, i.e.,
etwfe(..., cgroup = "never")in the original call.- lean
Logical. Default is
FALSE. Switching toTRUEenforces a lean return object; namely a simple data.frame of the main results, stripped of ancillary attributes. Note that this will disable some advancedmarginaleffectspost-processing features, but those are unlikely to be used in theemfxcontext. The upside is a potentially dramatic reduction in the size of the return object. Consequently, we may change the default toTRUEin a future version of etwfe.- ...
Additional arguments passed to
marginaleffects::slopes. For example, you can passvcov = FALSEto skip standard errors entirely, or your ownvcovargument (see the "Standard errors" section below). Another potentially useful application is testing whether heterogeneous treatment effects (i.e., the levels of anyxvarcovariate) are equal by invoking thehypothesisargument, e.g.hypothesis = "b1 = b2".
Value
A data.frame of aggregated treatment effects along the
dimension(s) of interested. Note that this data.frame will have been
overloaded with the slopes class, and so
will come with a special print method. But the underlying columns will
usually include:
termcontrast<type>(i.e., the name of yourtypestring)estimatestd.errorstatisticp.values.valueconf.lowconf.high
Standard errors
As of etwfe v0.7.0, emfx computes its standard errors using a fast and
exact analytic path, which yields both greater performance and precision
than the old numeric approach (which was based on the delta method).
Moreover, we now also report "unconditional" standard errors by default,
which account for sampling variation in the covariate distribution over
which the treatment effects are averaged (see:
marginaleffects::vcovUnconditional). Any one-way clustering in the
original etwfe model (e.g., vcov = ~countyreal) is carried over
automatically.
Exceptions to this rule, where emfx instead falls back to the model's own
(conditional) variance-covariance matrix, are as follows:
Compressed data (
compress = TRUE), since the compressed groups no longer map to the original observations. (Explicitly requesting unconditional standard errors for compressed data, e.g.vcov = "unconditional", is an error.)Multiway or interacted clustering (e.g.,
vcov = ~countyreal + year), whichvcovUnconditionaldoes not support.
A warning is triggered in both cases.
Users can override the default by passing their own vcov argument, e.g.
vcov = TRUE to recover the model's conditional standard errors (the
default behaviour prior to etwfe v0.7.0), or
vcov = marginaleffects::vcovUnconditional(type = "HC1", cluster = ~id) for
finer control.
Performance
Both etwfe and emfx should be fast, even for large datasets. In
particular, as of etwfe v0.7.0, emfx is orders of magnitude faster
than it used to be, because it now computes standard errors using exact
analytic Jacobians. (Earlier versions relied on a numerical delta method,
which required estimating two prediction models for each coefficient in
the model.)
Heterogeneous treatment effects
Specifying etwfe(..., xvar = <xvar>) will generate interaction effects
for all levels of <xvar> as part of the main regression model. The
reason that this is useful (as opposed to a regular, non-interacted
covariate in the formula RHS) is that it allows us to estimate
heterogeneous treatment effects as part of the larger ETWFE framework.
Specifically, we can recover heterogeneous treatment effects for each
level of <xvar> by passing the resulting etwfe model object on to
emfx().
For example, imagine that we have a categorical variable called "age" in
our dataset, with two distinct levels "adult" and "child". Running
emfx(etwfe(..., xvar = age)) will tell us how the efficacy of treatment
varies across adults and children. We can then also leverage the in-built
hypothesis testing infrastructure of marginaleffects to test whether
the treatment effect is statistically different across these two age
groups; see Examples below. Note the same principles carry over to
categorical variables with multiple levels, or even continuous variables
(although continuous variables are not as well supported yet).
References
Wong, Jeffrey et al. (2021). You Only Compress Once: Optimal Data Compression for Estimating Linear Models. Working paper (version: March 16, 2021). Available: https://doi.org/10.48550/arXiv.2102.11297
See also
marginaleffects::slopes which does the heavily lifting behind the
scenes. etwfe is the companion estimating function that should be run
before emfx.
Examples
# We'll use the base_stagg dataset from the fixest package, which simulates a
# staggered treatment rollout across 100 units over 10 years. Never-treated
# units have `year_treated = 10000`.
data("base_stagg", package = "fixest")
#
# Basic example
#
# The basic ETWFE workflow involves two consecutive function calls:
# 1) `etwfe` and 2) `emfx`
# 1) `etwfe`: Estimate a regression model with saturated interaction terms.
mod = etwfe(
fml = y ~ x1, # outcome ~ controls (use 0 or 1 if none)
tvar = year, # time variable
gvar = year_treated, # group variable
data = base_stagg, # dataset
vcov = ~id # vcov adjustment (here: clustered by unit)
)
# mod ## A fixest model object with fully saturated interaction effects.
# 2) `emfx`: Recover the treatment effects of interest.
(mod_es = emfx(mod, type = "event")) # dynamic ATE a la an event study
#>
#> event Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 0 -5.195 0.351 -14.79 <0.001 162.0 -5.884 -4.5070
#> 1 -3.467 0.360 -9.64 <0.001 70.7 -4.172 -2.7625
#> 2 -2.049 0.299 -6.86 <0.001 37.1 -2.635 -1.4637
#> 3 -0.881 0.411 -2.15 0.0319 5.0 -1.686 -0.0764
#> 4 0.917 0.358 2.56 0.0103 6.6 0.216 1.6186
#> 5 2.047 0.429 4.77 <0.001 19.1 1.206 2.8886
#> 6 4.150 0.427 9.72 <0.001 71.8 3.314 4.9872
#> 7 4.509 0.386 11.68 <0.001 102.3 3.752 5.2651
#> 8 5.220 0.742 7.04 <0.001 38.9 3.766 6.6734
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
#>
# Etc. Other aggregation type options are "simple" (the default), "group"
# and "calendar"
# To visualize results, use the native plot method (see `?plot.emfx`)
plot(mod_es)
# Aside: With R >= 4.1.0, you can also chain the whole workflow together with
# the native pipe, e.g.:
# etwfe(
# fml = y ~ x1,
# tvar = year,
# gvar = year_treated,
# data = base_stagg,
# vcov = ~id
# ) |>
# emfx(type = "event") |>
# plot()
# Notice that we don't get any pre-treatment effects with the default
# "notyet" treated control group. Switch to the "never" treated control
# group if you want this.
mod_never = etwfe(
y ~ x1, tvar = year, gvar = year_treated, data = base_stagg,
vcov = ~id,
cgroup = "never" ## <= use never treated group as control
)
plot(emfx(mod_never, "event"))
#
# Heterogeneous treatment effects
#
# Example where we estimate heterogeneous treatment effects for two
# (arbitrary) groups of units.
base_stagg$grp = base_stagg$id > 50
hmod = etwfe(
y ~ x1, tvar = year, gvar = year_treated, data = base_stagg,
vcov = ~id,
xvar = grp ## <= het. TEs by grp
)
# Heterogeneous ATEs (could also specify "event", etc.)
emfx(hmod)
#>
#> .Dtreat grp Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE FALSE -1.603 0.596 -2.69 0.0071 7.1 -2.77 -0.436
#> TRUE TRUE -0.773 0.662 -1.17 0.2431 2.0 -2.07 0.525
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
#>
# To test whether the ATEs across these two groups are statistically
# different, simply pass an appropriate "hypothesis" argument.
emfx(hmod, hypothesis = "b1 = b2")
#> Warning:
#> It is essential to check the order of estimates when specifying hypothesis tests using positional indices like b1, b2, etc. The indices of estimates can change depending on the order of rows in the original dataset, user-supplied arguments, model-fitting package, and version of `marginaleffects`.
#>
#> It is also good practice to use assertions that ensure the order of estimates is consistent across different runs of the same code. Example:
#>
#> ```r
#> mod <- lm(mpg ~ am * carb, data = mtcars)
#>
#> # assertion for safety
#> p <- avg_predictions(mod, by = 'carb')
#> stopifnot(p$carb[1] == 1, p$carb[2] == 2)
#>
#> # hypothesis test
#> avg_predictions(mod, by = 'carb', hypothesis = 'b1 - b2 = 0')
#> ```
#>
#> Disable this warning with: `options(marginaleffects_safe = FALSE)`
#> This warning appears once per session.
#>
#> Hypothesis Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> b1=b2 -0.83 0.879 -0.945 0.345 1.5 -2.55 0.892
#>
#> Type: response
#>
plot(emfx(hmod))
#
# Nonlinear model (distribution / link) families
#
# Poisson example (using a transformed, strictly positive outcome)
base_stagg$y_exp = exp(base_stagg$y / 10)
pmod = etwfe(
y_exp ~ x1, tvar = year, gvar = year_treated, data = base_stagg,
vcov = ~id,
family = "poisson" ## <= family arg for nonlinear options
)
#> The variables '.Dtreat:year_treated::3:year::2',
#> '.Dtreat:year_treated::4:year::2', '.Dtreat:year_treated::4:year::3',
#> '.Dtreat:year_treated::5:year::2', '.Dtreat:year_treated::5:year::3',
#> '.Dtreat:year_treated::5:year::4' and 66 others have been removed because of
#> collinearity (see $collin.var).
emfx(pmod, "event")
#>
#> event Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 0 -0.412 0.0268 -15.39 < 0.001 175.0 -0.4647 -0.3597
#> 1 -0.316 0.0362 -8.72 < 0.001 58.4 -0.3864 -0.2446
#> 2 -0.189 0.0300 -6.30 < 0.001 31.7 -0.2483 -0.1305
#> 3 -0.101 0.0457 -2.20 0.02755 5.2 -0.1904 -0.0111
#> 4 0.118 0.0413 2.86 0.00421 7.9 0.0372 0.1991
#> 5 0.275 0.0576 4.77 < 0.001 19.1 0.1621 0.3880
#> 6 0.674 0.0835 8.07 < 0.001 50.4 0.5107 0.8380
#> 7 0.703 0.1041 6.75 < 0.001 36.0 0.4990 0.9070
#> 8 1.176 0.2346 5.01 < 0.001 20.8 0.7165 1.6362
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
#>
#
# Non-binary treatments
#
# Walmart store openings and county retail employment, where the treatment
# is the number of Walmart stores in a county. Note that `ivar` is required.
data("walmart", package = "etwfe")
wmod = etwfe(
log_retail_emp ~ 0, tvar = year, gvar = cohort, data = walmart,
ivar = fips, vcov = ~fips,
wvar = n_open ## <= non-binary treatment
)
emfx(wmod) ## effect of each additional store
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE 0.0386 0.00506 7.63 <0.001 45.3 0.0287 0.0485
#>
#> Term: n_open
#> Type: response
#> Comparison: +1
#>
# Effects at specific treatment levels (relative to no treatment)
plot(emfx(wmod, "event", wlevels = 1:3))