Add unconditional variance for averaged marginal effects - #1737
Add unconditional variance for averaged marginal effects#1737pedrohcgs wants to merge 21 commits into
Conversation
|
Pedro, Thanks so much for this. I've been wanting to get this for a long time but never got to it. Your API is much better than the other PR. This should definitely go through the I'm not sure we want to export a I'm traveling now but will review as soon as I find some time. Thanks! |
|
This looks great! I'm also out traveling / on vacation, so won't be able to review carefully for a couple of weeks. But happy to do so when I get back if there's still need. |
|
Thanks again for the implementation @pedrohcgs ! I made some changes:
No rush for @grantmcdermott, but I'd really love your feedback on this, especially on @ngreifer also expressed interest in the other thread, so tagging here in case you want to check this out. And @snhansen, I'd be especially grateful for a review by you, since this implements several things from (or related to) your paper. Edit: one more thing that would be nice would be a |
|
This is incredible and much-needed work, thank you so much @pedrohcgs for taking it on and @vincentarelbundock for pushing it through. I'm eager to validate this against the implementation in adrftools. For multiply imputed data, I think it makes sense to just estimate the unconditional variance in each dataset and pool the final SEs together. For survey objects, I use this code in adrftools. It's a simple matter of extracting the influence function from a |
|
Thanks a lot for this, @ngreifer , super helpful! I added support for multiple imputation and the Things look pretty good at Let me know if you think of other cases to add, or if you spot any issues: |
|
Looks great to me! I don't think there should be any trouble supporting multinomial/ordinal models, as long as they have methods of extracting the influence functions. I know Also just FYI, in survey is the function |
Finally took a quick pass at this. As you know, my main interest is seeing how well this plays with etwfe. I think the answer is "nicely", but please note that I haven't vetted my results against known benchmarks. (@vincentarelbundock did you ever get around to adding a canonical In the absence of known results, the thing I'm most concerned about is making sure that there's no "double counting" of the VCOV via etwfe's consecutive two-step calls: library(etwfe)
library(marginaleffects)
data("mpdta", package = "did")
mod = etwfe(
fml = lemp ~ lpop,
tvar = year,
gvar = first.treat,
data = mpdta,
vcov = ~countyreal # clustered vcov
)
mod2 = etwfe(
fml = lemp ~ lpop,
tvar = year,
gvar = first.treat,
data = mpdta,
# vcov = ~countyreal # iid
)
emfx(mod, vcov = vcovUnconditional(cluster = ~countyreal)) # ATT from clustered 1st step
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE -0.0506 0.013 -3.89 <0.001 13.3 -0.0761 -0.0251
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
emfx(mod2, vcov = vcovUnconditional(cluster = ~countyreal)) # ATT from iid 1st step
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE -0.0506 0.013 -3.89 <0.001 13.3 -0.0761 -0.0251
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSESo, that's good news. On the other hand, I wonder whether we couldn't do something smart whereby, if the user passed a clustered VCOV in the first stage, then emfx(mod, vcov = vcovUnconditional()) # ATT from clustered 1st step
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE -0.0506 0.0404 -1.25 0.21 2.3 -0.13 0.0286
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
emfx(mod2, vcov = vcovUnconditional()) # ATT from iid 1st step
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE -0.0506 0.0404 -1.25 0.21 2.3 -0.13 0.0286
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSEP.S. For posterity, confirming that emfx(mod)
#>
#> .Dtreat Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> TRUE -0.0506 0.0125 -4.05 <0.001 14.3 -0.0751 -0.0261
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSEAnd also in event study form: emfx(mod, "event")
#>
#> event Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 0 -0.0332 0.0134 -2.48 0.013 6.3 -0.0594 -0.00702
#> 1 -0.0573 0.0171 -3.34 <0.001 10.2 -0.0910 -0.02373
#> 2 -0.1379 0.0308 -4.48 <0.001 17.0 -0.1982 -0.07753
#> 3 -0.1095 0.0323 -3.39 <0.001 10.5 -0.1729 -0.04620
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE
emfx(mod, "event", vcov = vcovUnconditional(cluster = ~countyreal))
#>
#> event Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 0 -0.0332 0.0135 -2.46 0.0141 6.2 -0.0597 -0.0067
#> 1 -0.0573 0.0180 -3.19 0.0014 9.5 -0.0925 -0.0222
#> 2 -0.1379 0.0339 -4.07 <0.001 14.4 -0.2042 -0.0715
#> 3 -0.1095 0.0323 -3.39 <0.001 10.5 -0.1728 -0.0463
#>
#> Term: .Dtreat
#> Type: response
#> Comparison: TRUE - FALSE |
|
Update: It turns out I still have access to a Stata license through work. I just tested against . qui jwdid lemp lpop, ivar(county) time(year) gvar(first_treat) method(regress)
. estat simple, vce(unconditional)
(Std. err. adjusted for 500 clusters in countyreal)
------------------------------------------------------------------------------
| Unconditional
| Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
simple | -.050627 .0130756 -3.87 0.000 -.076317 -.0249371
------------------------------------------------------------------------------
. estat event, vce(unconditional)
(Std. err. adjusted for 500 clusters in countyreal)
------------------------------------------------------------------------------
| Unconditional
| Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
__event__ |
0 | -.0332122 .0136055 -2.44 0.015 -.0599432 -.0064812
1 | -.0573456 .0180591 -3.18 0.002 -.0928269 -.0218644
2 | -.1378704 .0340633 -4.05 0.000 -.2047956 -.0709451
3 | -.1095395 .0324765 -3.37 0.001 -.1733469 -.045732
------------------------------------------------------------------------------Compare my results from above (coercing to regular data.frame for more decimals): > emfx(mod, vcov = vcovUnconditional(cluster = ~countyreal)) |> as.data.frame()
term contrast .Dtreat estimate std.error statistic p.value s.value conf.low conf.high
<chr> <chr> <lgl> <num> <num> <num> <num> <num> <num> <num>
1 .Dtreat TRUE - FALSE TRUE -0.05062703 0.01299948 -3.894542 9.838462e-05 13.31121 -0.07610555 -0.02514851
> emfx(mod, 'event', vcov = vcovUnconditional(cluster = ~countyreal)) |> as.data.frame()
term contrast event estimate std.error statistic p.value s.value conf.low conf.high
<chr> <chr> <num> <num> <num> <num> <num> <num> <num> <num>
1 .Dtreat TRUE - FALSE 0 -0.03321220 0.01352628 -2.455383 1.407346e-02 6.150879 -0.05972323 -0.006701176
2 .Dtreat TRUE - FALSE 1 -0.05734565 0.01795403 -3.194027 1.403028e-03 9.477240 -0.09253489 -0.022156405
3 .Dtreat TRUE - FALSE 2 -0.13787039 0.03386512 -4.071162 4.677921e-05 14.383773 -0.20424480 -0.071495972
4 .Dtreat TRUE - FALSE 3 -0.10953946 0.03228746 -3.392631 6.922473e-04 10.496425 -0.17282172 -0.046257194 |
|
I will test this here with Stata, too.
I have played enough with jwdid enough. Plus, I can also validate this if
it is numerically the same as csdid when they must match.
…----------------------------------------------------
*Pedro H. C. Sant'Anna*
*https://psantanna.com <https://psantanna.com>*
----------------------------------------------------
Warning: This email may contain confidential or privileged information
intended only for the use of the individual or entity to whom it is
addressed. If you are not the intended recipient, please understand
that any disclosure, copying, distribution, or use of the contents
of this email is strictly prohibited.
----------------------------------------------------
On Fri, Jul 31, 2026 at 17:10 Grant McDermott ***@***.***> wrote:
*grantmcdermott* left a comment (vincentarelbundock/marginaleffects#1737)
<#1737 (comment)>
Update: It turns out I still have access to a Stata license through work.
I just tested against jwdid (guide
<https://friosavila.github.io/app_metrics/app_metrics8.html>) and the
resulting SEs are very close... but not *exactly* the same as I'm getting.
. qui jwdid lemp lpop, ivar(county) time(year) gvar(first_treat) method(regress)
. estat simple, vce(unconditional)
(Std. err. adjusted for 500 clusters in countyreal)------------------------------------------------------------------------------
| Unconditional
| Coefficient std. err. t P>|t| [95% conf. interval]-------------+----------------------------------------------------------------
simple | -.050627 .0130756 -3.87 0.000 -.076317 -.0249371------------------------------------------------------------------------------
. estat event, vce(unconditional)
(Std. err. adjusted for 500 clusters in countyreal)------------------------------------------------------------------------------
| Unconditional
| Coefficient std. err. t P>|t| [95% conf. interval]-------------+----------------------------------------------------------------
__event__ |
0 | -.0332122 .0136055 -2.44 0.015 -.0599432 -.0064812
1 | -.0573456 .0180591 -3.18 0.002 -.0928269 -.0218644
2 | -.1378704 .0340633 -4.05 0.000 -.2047956 -.0709451
3 | -.1095395 .0324765 -3.37 0.001 -.1733469 -.045732------------------------------------------------------------------------------
Compare my results from above (coercing to regular data.frame for more
decimals):
> emfx(mod, vcov = vcovUnconditional(cluster = ~countyreal)) |> as.data.frame()
term contrast .Dtreat estimate std.error statistic p.value s.value conf.low conf.high
<chr> <chr> <lgl> <num> <num> <num> <num> <num> <num> <num>1 .Dtreat TRUE - FALSE TRUE -0.05062703 0.01299948 -3.894542 9.838462e-05 13.31121 -0.07610555 -0.02514851
> emfx(mod, 'event', vcov = vcovUnconditional(cluster = ~countyreal)) |> as.data.frame()
term contrast event estimate std.error statistic p.value s.value conf.low conf.high
<chr> <chr> <num> <num> <num> <num> <num> <num> <num> <num>1 .Dtreat TRUE - FALSE 0 -0.03321220 0.01352628 -2.455383 1.407346e-02 6.150879 -0.05972323 -0.0067011762 .Dtreat TRUE - FALSE 1 -0.05734565 0.01795403 -3.194027 1.403028e-03 9.477240 -0.09253489 -0.0221564053 .Dtreat TRUE - FALSE 2 -0.13787039 0.03386512 -4.071162 4.677921e-05 14.383773 -0.20424480 -0.0714959724 .Dtreat TRUE - FALSE 3 -0.10953946 0.03228746 -3.392631 6.922473e-04 10.496425 -0.17282172 -0.046257194
—
Reply to this email directly, view it on GitHub
<#1737?email_source=notifications&email_token=ABE734Y77WBS5YGGLWGDECD5HUDNHA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMJUG42DSNBRGA42M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-5147494109>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABE734YT53VGI4RZFN4LEYD5HUDNHAVCNFSNUABFKJSXA33TNF2G64TZHM2DAMZWGI2TOMJVHNEXG43VMU5TIOBTGM2TKNZWGE42C5QC>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
Hi Vincent,
I am opening this as a draft because it overlaps with the motivation in #1240 and the prototype in #1711, but it takes a different implementation path. Rather than adding a standalone post-processing helper, this tries to make unconditional variance a regular
vcovoption inside the existingmarginaleffectsworkflow.The practical motivation is my own workflow with
etwfe. I have been usingetwfefor applied work where I need averaged/aggregated marginal effects, and the usual conditional standard errors miss an important part of the uncertainty because the empirical covariate distribution is treated as fixed. This PR is an attempt to make that workflow reliable directly inmarginaleffects, instead of carrying around local patches or ad hoc post-processing code.Tagging @grantmcdermott because this came up for me through
etwfe, and he may have useful thoughts on whether the API and scope make sense for that use case.What this adds
The new user-facing entry points are:
vcov = "unconditional"vcov = unconditional(~cluster)The feature accounts for sampling variation in the empirical covariate distribution when computing averaged or aggregated predictions, comparisons, and slopes.
I tried to keep this additive and conservative. The implementation reuses the existing prediction/comparison plans, aggregation logic, Jacobian machinery, and result finalization instead of building a separate parallel path.
Current scope
Supported workflows include averaged or aggregated:
The code intentionally rejects cases where the empirical-distribution influence function is not well-defined, or where important uncertainty components are not represented.
Currently unsupported:
hypotheses()calls on model objectscoxphprediction typesValidation
The new tests cover average predictions, comparisons, and slopes; robust and clustered unconditional variance; degrees-of-freedom behavior; transformations;
by; weights; offsets; custom hypotheses; scalar and pairwise comparisons; counterfactual grids; valid subsets of original model data; high-dimensional covariate settings; optionalfixest,etwfe,survival,AER,survey, andmicepaths; and explicit errors for unsupported model/data structures.Local checks before opening the draft:
R CMD buildpassesR CMD checkpasses, including vignettes and PDF manualI do not expect this to be the final shape of the API or internals. The main thing I would like feedback on is whether integrating this through
vcovis the right direction, and whether the current conservative set of supported cases is a reasonable starting point.