A fitted lm_robust
object is handed to many functions estimatr did not write:
coef(), predict(), confint(),
anova(), hatvalues(),
sandwich::vcovHC(), and dozens more. Some of these have
estimatr methods, some fall through to a method written for
lm, and some have no business running on a fit that does
not store residuals or a QR decomposition. The post-estimation contract
records, for every one of 72 probes on each of 11 fits, what estimatr
promises the call will do. The table below is rendered from
inst/contract/contract.csv, the same file the test suite
reads, so the page cannot drift from what is checked.
Each cell carries exactly one promise:
| code | promise | meaning |
|---|---|---|
= |
agrees | returns the same answer as the call on the reference fit
(lm(), or ivreg::ivreg() for the IV fits), or
on a stated reference |
R |
refuses | stops with estimatr’s own message saying the generic is not available for this fit |
E |
errors | stops, rather than return something that would mislead |
~ |
returns | returns a value, with no reference answer to hold it to |
! |
known_wrong | a recorded gap; the test fails if it starts agreeing, so a fix is entered here |
Across the 792 cells: 378 agree, 169 refuse, 223 error, 22 return, 0 are known wrong, and 0 make no promise.
The test (tests/testthat/test_contract.R) runs each
probe in a fresh R process holding estimatr and only the packages that
probe needs, and runs the reference in a process that never loads
estimatr. Isolation matters here: clubSandwich replaces
estimatr’s bread() and model.frame() methods
when it is loaded second, and performance registers a
logLik() method for iv_robust, so a probe run
beside them measures them rather than estimatr.
One rule assigns every promise, so that the next generic is placed by it rather than by taste.
lm() or
ivreg::ivreg() answers the same call and the fit stores
what the answer needs. Where estimatr has a method, it returns that
answer; where it has none, the fall-through must already be right.difference_in_means() and horvitz_thompson()
store no residuals and no design, a fixed_effects fit does
not store its absorbed design, a fit with several outcomes has one
residual matrix for them all, and case-deletion diagnostics need
leave-one-out refits that are not computed. A refusal replaces a
NULL, a wrong number, or an unhelpful error with a
sentence; it never replaces a working answer.logLik(), AIC(), and BIC() are
the case to know: the estimators assume no distribution, and estimatr
registers no logLik() on any class, because performance
registers one for iv_robust() fits and a refusal on
lm_robust() fits alone would make the answer depend on
which package loaded last. The rest are generics that read internals an
estimatr fit does not have, such as qr(),
effects(), alias(), and
anova().horvitz_thompson(),
which has no reference fit; summary(fit)$fstatistic where
the fit’s F statistic is a robust Wald statistic rather than
lm()’s; and terms() and labels()
on lm_lin(), whose translated formula has no
counterpart.All eleven are fit to one simulated dataset of 60 rows with a binary
treatment z, a covariate x, a binary
instrument iv, ten clusters g, a four-level
factor f, weights w (three of them zero), and
a second outcome y2.
| fit | estimatr call | reference |
|---|---|---|
lm_plain |
lm_robust(y ~ x + z, d) |
lm(y ~ x + z, d) |
lm_wt0 |
lm_robust(y ~ x + z, d, weights = w) |
lm(y ~ x + z, d, weights = w) |
lm_cl |
lm_robust(y ~ x + z, d, clusters = g) |
lm(y ~ x + z, d) |
lm_fe |
lm_robust(y ~ x + z, d, fixed_effects = ~f) |
lm(y ~ x + z + f, d) |
lm_mv |
lm_robust(cbind(y, y2) ~ x + z, d) |
lm(cbind(y, y2) ~ x + z, d) |
lm_lin |
lm_lin(y ~ z, ~x, d) |
lm(y ~ z + x_c + z:x_c, d) |
iv_plain |
iv_robust(y ~ z + x \| iv + x, d) |
ivreg::ivreg(y ~ z + x \| iv + x, data = d) |
iv_cl |
iv_robust(y ~ z + x \| iv + x, d, clusters = g) |
ivreg::ivreg(y ~ z + x \| iv + x, data = d) |
dim |
difference_in_means(y ~ z, d) |
lm(y ~ z, d) |
ht |
horvitz_thompson(y ~ z, d, condition_prs = ...) |
none |
lh |
lh_robust(y ~ x + z, data = d, linear_hypothesis = "z = 0") |
lm(y ~ x + z, d) |
The reference fits report classical variances, so a cell that
concerns a variance, a standard error, or an interval refers instead to
the variance estimatr reports by default:
sandwich::vcovHC(ref, type = "HC2"), or
clubSandwich::vcovCR(ref, type = "CR2") with Satterthwaite
degrees of freedom for the clustered fits. lm_wt0’s
variances refer to the same regression on its positive-weight rows,
because sandwich counts zero-weight rows as observations.
x_c is x centered, as lm_lin()
centers it. dim and ht equal a regression on
z only where a row says so.
| call | lm_plain | lm_wt0 | lm_cl | lm_fe | lm_mv | lm_lin | iv_plain | iv_cl | dim | ht | lh |
|---|---|---|---|---|---|---|---|---|---|---|---|
coef(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
coefficients(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
residuals(fit) |
= | = | = | = | = | = | = | = | R | R | = |
resid(fit) |
= | = | = | = | = | = | = | = | R | R | = |
fitted(fit) |
= | = | = | = | = | = | = | = | R | R | = |
fitted.values(fit) |
= | = | = | = | = | = | = | = | R | R | = |
predict(fit, newdata = d) |
= | = | = | = | = | = | = | = | R | R | = |
predict(fit, newdata = d, se.fit = TRUE)$se.fit |
= | = | = | R | R | = | = | = | R | R | = |
predict(fit) |
= | = | = | = | = | = | = | = | R | R | = |
confint(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
vcov(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
nobs(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
df.residual(fit) |
= | = | = | = | = | = | = | = | R | R | = |
deviance(fit) |
= | = | = | = | R | = | = | = | R | R | = |
as.numeric(logLik(fit)) |
E | E | E | E | E | E | E | E | E | E | E |
AIC(fit) |
E | E | E | E | E | E | E | E | E | E | E |
BIC(fit) |
E | E | E | E | E | E | E | E | E | E | E |
extractAIC(fit) |
E | E | E | E | E | E | E | E | E | E | E |
sigma(fit) |
= | = | = | = | R | = | = | = | R | R | = |
format(formula(fit)) |
= | = | = | = | = | = | = | = | = | ~ | = |
attr(terms(fit), "term.labels") |
= | = | = | = | = | ~ | = | = | R | R | = |
mf <- model.frame(fit); as.matrix(mf[vapply(mf, is.numeric, TRUE)]) |
= | = | = | = | = | = | = | = | R | R | = |
model.matrix(fit) |
= | = | = | = | = | = | = | = | R | R | = |
model.matrix(fit, data = transform(d, x = x + 1, x_c = x_c + 1)) |
= | = | = | = | = | = | = | = | R | R | = |
family(fit)$family |
E | E | E | E | E | E | E | E | E | E | E |
weights(fit) |
= | = | = | = | = | = | = | = | = | ~ | = |
case.names(fit) |
= | = | = | = | = | = | = | = | R | R | = |
variable.names(fit) |
= | = | = | = | = | = | = | = | R | R | = |
labels(fit) |
= | = | = | = | = | ~ | = | = | R | R | = |
unlist(dummy.coef(fit)) |
E | E | E | E | E | E | E | E | E | E | E |
effects(fit) |
E | E | E | E | E | E | E | E | E | E | E |
alias(fit)$Complete |
E | E | E | E | E | E | E | E | E | E | E |
qr(fit)$rank |
E | E | E | E | E | E | E | E | E | E | E |
kappa(fit) |
E | E | E | E | E | E | E | E | E | E | E |
proj(fit) |
E | E | E | E | E | E | E | E | E | E | E |
hatvalues(fit) |
= | = | = | R | R | = | = | = | R | R | = |
cooks.distance(fit) |
R | R | R | R | R | R | R | R | R | R | R |
influence(fit)$hat |
R | R | R | R | R | R | R | R | R | R | R |
lm.influence(fit)$hat |
E | E | E | E | E | E | E | E | R | R | E |
influence.measures(fit)$infmat |
E | E | E | E | E | E | E | E | R | R | E |
dfbeta(fit) |
R | R | R | R | R | R | R | R | R | R | R |
dfbetas(fit) |
R | R | R | R | R | R | R | R | R | R | R |
rstandard(fit) |
R | R | R | R | R | R | R | R | R | R | R |
rstudent(fit) |
R | R | R | R | R | R | R | R | R | R | R |
add1(fit, ~. + x2, data = d) |
E | E | E | E | E | E | E | E | R | R | E |
drop1(fit) |
E | E | E | E | E | E | E | E | R | R | E |
formula(step(fit, trace = 0)) |
E | E | E | E | E | E | E | E | R | R | E |
anova(fit) |
E | E | E | E | E | E | E | E | E | E | E |
anova(fit, update(fit, . ~ . - x)) |
E | E | E | E | E | E | E | E | E | E | E |
coef(update(fit, . ~ . - x)) |
= | = | = | = | = | R | = | = | = | ~ | = |
coef(update(fit, . ~ . + x2)) |
= | = | = | = | = | = | = | = | R | R | = |
simulate(fit, 1) |
E | E | E | E | E | E | E | E | E | E | E |
plot(fit, which = 1) |
E | E | E | E | E | E | E | E | E | E | E |
coef(summary(fit)) |
= | = | = | = | = | = | = | = | = | ~ | = |
summary(fit)$sigma |
= | = | = | = | = | = | = | = | = | ~ | = |
summary(fit)$r.squared |
= | = | = | = | = | = | = | = | = | ~ | = |
summary(fit)$fstatistic |
= | = | ~ | ~ | ~ | = | ~ | ~ | ~ | ~ | = |
fit$model |
= | = | = | = | = | = | = | = | = | ~ | = |
fit$na.action |
= | = | = | = | = | = | = | = | = | ~ | = |
sandwich::bread(fit) |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::estfun(fit) |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::meat(fit) |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::sandwich(fit) |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::vcovHC(fit, type = "HC2") |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::vcovHC(fit, type = "HC3") |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::vcovHC(fit, type = "HC0") |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::vcovCL(fit, cluster = d$g) |
= | E | = | R | R | = | = | = | R | R | = |
sandwich::vcovCL(fit, cluster = d$g, type = "HC2") |
= | E | = | R | R | = | = | = | R | R | = |
sandwich::vcovHAC(fit) |
= | = | = | R | R | = | = | = | R | R | = |
sandwich::vcovBS(fit, R = 20) |
E | E | E | E | E | E | E | E | E | E | E |
sandwich::vcovOPG(fit) |
= | = | = | R | R | = | = | = | R | R | = |
bread(fit) %*% meat(fit) %*% bread(fit) / nobs(fit) |
= | = | = | R | R | = | = | = | R | R | = |
A refusal names what the fit lacks. These are the messages, and the cells that carry them:
| message | cells | fits |
|---|---|---|
bread() is not available |
bread, sandwich | lm_fe, lm_mv, dim, ht |
cooks.distance() is not available |
cooks.distance | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
deviance() is not available |
deviance, sigma | lm_mv |
dfbeta() is not available |
dfbeta | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
dfbetas() is not available |
dfbetas | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
estfun() is not available |
estfun, meat, vcovHC, vcovCL, vcovHAC, vcovOPG | lm_fe, lm_mv, dim, ht |
fitted() is not available |
fitted.values | ht |
hatvalues() is not available |
hatvalues | lm_fe, lm_mv |
influence() is not available |
influence | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
model.matrix() is not available |
vcovHC | dim, ht |
residuals() is not available |
resid, lm.influence, influence.measures | dim, ht |
rstandard() is not available |
rstandard | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
rstudent() is not available |
rstudent | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
terms() is not available |
add1, drop1, step | dim, ht |
Can’t set se.fit == TRUE with multivariate
outcome |
predict | lm_mv |
Can’t set se.fit or interval
with fixed_effects |
predict | lm_fe |
| cannot remove every covariate | update | lm_lin |
| not built from a design matrix | terms, model.matrix, bread | dim, ht |
| only one variable on the right-hand side | update | dim, ht |
| the estimator has no residuals | df.residual | dim, ht |
| the estimator has no residuals or fitted values | residuals, resid, fitted, fitted.values, deviance, sigma | dim, ht |
| the estimator is not built from a design matrix | predict, variable.names, labels, hatvalues, cooks.distance, influence, dfbeta, dfbetas, rstandard, rstudent, estfun | dim, ht |
the estimator is not built from a design matrix, so
there is no bread to pair with sandwich::meat() |
bread | dim, ht |
| the rows used are not stored on the fit | model.frame, case.names | dim, ht |
Some cells could reasonably have gone either way: whether
df.residual() reports N - K or the cluster-adjusted degrees
of freedom, whether logLik() on a robust fit should exist
at all. Where a cell’s promise was a choice rather than a consequence,
the maintainer’s decision is recorded beside it:
| decision | promise | generics | fits |
|---|---|---|---|
| refused | refuses | residuals, resid, fitted, fitted.values, deviance, sigma, case.names, variable.names, labels | dim |
| refuse what reads residuals or a design on dim and ht | refuses | residuals, resid, fitted, fitted.values, df.residual, deviance, sigma, case.names, variable.names, labels | ht |
| refuse what reads residuals or a design on dim and ht | refuses | predict, hatvalues, cooks.distance, influence, lm.influence, influence.measures, dfbeta, dfbetas, rstandard, rstudent, add1, drop1, step, estfun, meat, sandwich, vcovHC, vcovCL, vcovHAC, vcovOPG, bread | dim, ht |
| multi-outcome refused | refuses | predict, deviance, sigma, hatvalues, bread, estfun, meat, sandwich, vcovHC, vcovCL, vcovHAC, vcovOPG | lm_mv |
| per outcome, as lm() reports them | agrees | confint, summary | lm_mv |
| N - K; consumers get upstream fixes | agrees | df.residual | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
| refused; the tests use fit$df | refuses | df.residual | dim |
| implement with lm’s meaning | agrees | deviance, sigma | lm_plain, lm_wt0, lm_cl, lm_fe, lm_lin, iv_plain, iv_cl, lh |
| no logLik() method on any class; performance registers one for iv_robust, so a refusal on lm_robust alone made AIC(fit) depend on load order | errors | logLik, AIC, BIC, extractAIC | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
| terms() full (lm_lin translated), formula() the call’s | agrees | formula | lm_fe, lm_lin |
| terms() full (lm_lin translated), formula() the call’s | agrees | terms, labels | lm_fe |
| terms() full (lm_lin translated), formula() the call’s | returns | terms, labels | lm_lin |
| the regressors, as ivreg’s terms() default to | agrees | terms | iv_plain, iv_cl |
| implement with lm’s meaning | agrees | case.names | lm_plain, lm_wt0, lm_cl, lm_fe, lm_lin, lh |
| implement with lm’s meaning; the reference has no case.names() of its own | agrees | case.names | lm_mv, iv_plain, iv_cl |
| implement with lm’s meaning | agrees | labels | lm_plain, lm_wt0, lm_cl, iv_plain, iv_cl, lh |
| implement with lm’s meaning | agrees | hatvalues | lm_plain, lm_wt0, lm_cl, lm_lin, iv_plain, iv_cl, lh |
| implement with lm’s meaning | refuses | hatvalues | lm_fe |
| refuse case-deletion diagnostics | refuses | cooks.distance, influence, dfbeta, dfbetas, rstandard, rstudent | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, lh |
| reaches extractAIC(), which has no method on any class | errors | add1 | lm_plain, lm_wt0, lm_cl, lm_mv, lh |
| reaches extractAIC(), which has no method on any class | errors | drop1, step | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, lh |
| covariates on the right-hand side are refused, as difference_in_means() refuses them | refuses | update | ht |
| summary() carries sigma, as summary.lm() does | agrees | summary | lm_plain, lm_wt0, lm_cl, lm_fe, lm_lin, iv_plain, iv_cl |
dim has no residual quantities; $ cannot
refuse, so NULL is the promise |
agrees | summary | dim |
| not stored, as lm(model = FALSE); consumers fall back to model.frame() | agrees | $model | lm_plain, lm_wt0, lm_cl, lm_fe, lm_mv, lm_lin, iv_plain, iv_cl, dim, lh |